Skip to main content

SEO 관련 포스팅

· 11 min read
brown
FE developer

우리 회사 사이트는 flynt다.

신생사이트는 일단 대중에게 노출되는 것이 무엇보다 중요하다. 이는 마케팅의 영역일 것이다.

이 점에 대해 프론트엔드 개발자로 기여 할 수 있는 부분은 SEO 관련 부분일 것이다.

검색엔진은 SEO가 잘 된 사이트에 더 높은 점수를 부여하고 사용자에게 더 잘 노출될 수 있게 한다.

SEO는 사실 복잡한 개념은 아니라고 생각한다.

  1. 사이트가 HTML 문서라는 점을 이해
  2. 메타태그로 사이트에 대한 설명
  3. Semantic한 태그를 사용
  4. 반응형 페이지 제공(다양한 기기에 대응)
  5. 콘텐츠가 사용자에게 빠르게 도달하게 노력

이정도면 충분하다고 생각한다. 과거에는 백링크의 수가 검색 결과 순위를 결정하는 주요 요인이었지만, 최근에는 실제 트래픽이 더욱 중요해졌다.

프론트에서 할 수 있는 부분을 놓치지 말고 진행하자.

최근 2달 정도의 회고

· 3 min read
brown
FE developer

한동안 포스트를 작성 하지 않았다.

여러가지 상황이 있었다.

  • 회사일이 바빴다.
  • 사이드 프로젝트를 시작했다.
  • 연애도 하고 있지 😵

열심히 살고 있는 것 같은데, 왜 이렇게 할게 늘기만 하는지 ㅋㅋ... 효율성이 중요한 시점이다.

간만에 포스트를 쓰는 이유는 심란해서이다.

솔라나 프로그램을 작성할 목적으로, 러스트 언어를 두달 정도 학습했었다.

이제 프로그램 분석하고 작성해보는 단계를 진행하고 있는데, 갑자기 왠 FTX 벼락 ㅠㅠ 인지 솔라나가 망하게 생겼다...

Rust + 스마트 컨트랙트를 둘다 만족시켜주는 선택지는 이렇게 사라지고 말았다.

자산도 다 코인으로 바꿔놨는데 하 인생...

2022년 마지막까지 열심히 살고, KRW 채굴 역량이나 올려야겠다!

Chap.9 error-handling

· 48 min read
brown
FE developer

러스트의 신뢰성에 대한 약속은 에러 처리에도 확장되어 있습니다.

에러는 소프트웨어에서 피할 수 없는 현실이며, 따라서 러스트는 무언가 잘못되었을 경우에 대한 처리를 위한 몇 가지 기능을 갖추고 있습니다.

러스트는 에러를 두 가지 범주로 묶습니다:

  • 복구 가능한(recoverable) 에러
    • 복구 가능한 에러는 사용자에게 문제를 보고하고 연산을 재시도하는 것이 보통 합리적인 경우인데, 이를테면 파일을 찾지 못하는 에러가 그렇습니다.
  • 복구 불가능한(unrecoverable) 에러
    • 복구 불가능한 에러는 언제나 버그의 증상이 나타나는데, 예를 들면 배열의 끝을 넘어선 위치의 값에 접근하려고 시도하는 경우가 그렇습니다.

대부분의 언어들은 이 두 종류의 에러를 분간하지 않으며 예외 처리(exception)와 같은 메카니즘을 이용하여 같은 방식으로 둘 다 처리합니다.

러스트는 예외 처리 기능이 없습니다.

  • 복구 가능한 에러를 위한 Result<T, E> 값과
  • 복구 불가능한 에러가 발생했을 때 실행을 멈추는 panic! 매크로를 가지고 있습니다.

이번 장에서는 panic!을 호출하는 것을 먼저 다룬 뒤, Result<T, E> 값을 반환하는 것에 대해 이야기 하겠습니다.

추가로, 에러로부터 복구을 시도할지 아니면 실행을 멈출지를 결정할 때 고려할 것에 대해 탐구해 보겠습니다.

panic!과 함께하는 복구 불가능한 에러

  • 러스트는 panic! 매크로를 가지고 있습니다.
  • 이 매크로가 실행되면, 여러분의 프로그램은 실패 메세지를 출력하고, 스택을 되감고 청소하고, 그 후 종료됩니다.
  • 이런 일이 발생하는 가장 흔한 상황은 어떤 종류의 버그가 발견되었고 프로그래머가 이 에러를 어떻게 처리할지가 명확하지 않을 때 입니다.

panic!에 응하여 스택을 되감거나 그만두기

기본적으로, panic!이 발생하면, 프로그램은 되감기(unwinding) 를 시작하는데, 이는 러스트가 패닉을 마주친 각 함수로부터 스택을 거꾸로 훑어가면서 데이터를 제거한다는 뜻이지만, 이 훑어가기 및 제거는 일이 많습니다.

다른 대안으로는 즉시 그만두기(abort) 가 있는데, 이는 데이터 제거 없이 프로그램을 끝내는 것입니다. 프로그램이 사용하고 있던 메모리는 운영체제에 의해 청소될 필요가 있을 것입니다. 여러분의 프로젝트 내에서 결과 바이너리가 가능한 작아지기를 원한다면, 여러분의 Cargo.toml 내에서 적합한 [profile] 섹션에 panic = 'abort'를 추가함으로써 되감기를 그만두기로 바꿀 수 있습니다.

예를 들면, 여러분이 릴리즈 모드 내에서는 패닉 상에서 그만두기를 쓰고 싶다면, 다음을 추가하세요: [profile.release] panic = 'abort'

단순한 프로그램 내에서 panic! 호출을 시도해 봅시다:

  • panic!("crash and burn");
  • panic!의 호출이 마지막 세 줄의 에러 메세지를 야기합니다.
  • 위 예제의 경우, 가리키고 있는 줄은 우리 코드 부분이고, 해당 줄로 가면 panic! 매크로 호출을 보게 됩니다.
  • 그 외의 경우들에서는, panic! 호출이 우리가 호출한 코드 내에 있을 수도 있습니다.
  • 에러 메세지에 의해 보고되는 파일 이름과 라인 번호는 panic! 매크로가 호출된 다른 누군가의 코드일 것이며, 궁극적으로 panic!을 이끌어낸 것이 우리 코드 라인이 아닐 것입니다.
  • 문제를 일으킨 코드 부분을 발견하기 위해서 panic! 호출이 발생된 함수에 대한 백트레이스(backtrace)를 사용할 수 있습니다.
  • 백트레이스가 무엇인가에 대해서는 뒤에 더 자세히 다를 것입니다.

panic! 백트레이스 사용하기

다른 예를 통해서, 우리 코드가 직접 매크로를 호출하는 대신 우리 코드의 버그 때문에 panic! 호출이 라이브러리로부터 발생될 때는 어떻게 되는지 살펴봅시다.

이러한 상황에서 C와 같은 다른 언어들은 여러분이 원하는 것이 아닐지라도, 여러분이 요청한 것을 정확히 주려고 시도할 것입니다: 여러분은 벡터 내에 해당 요소와 상응하는 위치의 메모리에 들어 있는 무언가를 얻을 것입니다. 설령 그 메모리 영역이 벡터 소유가 아닐지라도 말이죠.

이러한 것을 버퍼 오버리드(buffer overread) 라고 부르며, 만일 어떤 공격자가 읽도록 허용되어선 안 되지만 배열 뒤에 저장된 데이터를 읽어낼 방법으로서 인덱스를 다룰 수 있게 된다면, 이는 보안 취약점을 발생시킬 수 있습니다.

여러분의 프로그램을 이러한 종류의 취약점으로부터 보호하기 위해서, 여러분이 존재하지 않는 인덱스 상의 요소를 읽으려 시도한다면, 러스트는 실행을 멈추고 계속하기를 거부할 것입니다. 한번 시도해 봅시다:

  • 위 에러는 우리가 작성하지 않은 파일인 libcollections/vec.rs를 가리키고 있습니다.
  • 이는 표준 라이브러리 내에 있는 Vec<T>의 구현 부분입니다.
  • 우리가 벡터 v에 []를 사용할 때 실행되는 코드는 libcollections/vec.rs 안에 있으며, 그곳이 바로 panic!이 실제 발생한 곳입니다.
  • 그 다음 노트는 RUST_BACKTRACE 환경 변수를 설정하여 에러의 원인이 된 것이 무엇인지 정확하게 백트레이스할 수 있다고 말해주고 있습니다.
  • 백트레이스 (backtrace) 란 어떤 지점에 도달하기까지 호출해온 모든 함수의 리스트를 말합니다.
  • 러스트의 백트레이스는 다른 언어들에서와 마찬가지로 동작합니다: 백트레이스를 읽는 요령은 위에서부터 시작하여 여러분이 작성한 파일이 보일 때까지 읽는 것입니다. 그곳이 바로 문제를 일으킨 지점입니다.
  • 여러분의 파일을 언급한 줄보다 위에 있는 줄들은 여러분의 코드가 호출한 코드입니다; 밑의 코드는 여러분의 코드를 호출한 코드입니다.
  • 이 줄들은 핵심(core) 러스트 코드, 표준 라이브러리, 혹은 여러분이 이용하고 있는 크레이트를 포함하고 있을지도 모릅니다. 백트레이스를 얻어내는 시도를 해봅시다: Listing 9-2는 여러분이 보게 될 것과 유사한 출력을 보여줍니다:

환경 변수 RUST_BACKTRACE가 설정되었을 때 panic!의 호출에 의해 발생되는 백트레이스 출력

  • 이러한 정보들과 함께 백트레이스를 얻기 위해서는 디버그 심볼이 활성화되어 있어야 합니다.
  • 디버그 심볼은 여기서와 마찬가지로 여러분이 cargo build나 cargo run을 --release 플래그 없이 실행했을 때 기본적으로 활성화됩니다.

여러분의 코드가 추후 패닉에 빠졌을 때, 여러분의 특정한 경우에 대하여 어떤 코드가 패닉을 일으키는 값을 만드는지와 코드는 대신 어떻게 되어야 할지를 알아낼 필요가 있을 것입니다.

우리는 panic!으로 다시 돌아올 것이며 언제 panic!을 써야 하는지, 혹은 쓰지 말아야 하는지에 대해 이 장의 뒷부분에서 알아보겠습니다. 다음으로 Result를 이용하여 에러로부터 어떻게 복구하는지를 보겠습니다.

Result와 함께하는 복구 가능한 에러

대부분의 에러는 프로그램을 전부 멈추도록 요구될 정도로 심각하지는 않습니다. 종종 어떤 함수가 실패할 때는, 우리가 쉽게 해석하고 대응할 수 있는 이유에 대한 것입니다.

예를 들어, 만일 우리가 어떤 파일을 여는데 해당 파일이 존재하지 않아서 연산에 실패했다면, 프로세스를 멈추는 대신 파일을 새로 만드는 것을 원할지도 모릅니다

enum Result<T, E> {
Ok(T),
Err(E),
}
  • T와 E는 제네릭 타입 파라미터입니다;
  • 지금으로서 여러분이 알아둘 필요가 있는 것은, T는 성공한 경우에 Ok variant 내에 반환될 값의 타입을 나타내고 E는 실패한 경우에 Err variant 내에 반환될 에러의 타입을 나타내는 것이라는 점입니다.
  • Result가 이러한 제네릭 타입 파라미터를 갖기 때문에, 우리가 반환하고자 하는 성공적인 값과 에러 값이 다를 수 있는 다양한 상황 내에서 표준 라이브러리에 정의된 Result 타입과 함수들을 사용할 수 있습니다.

실패할 수도 있기 때문에 Result 값을 반환하는 함수를 호출해 봅시다

use std::fs::File;
fn main() {
let f = File::open("hello.txt");
}
  • File::open이 Result를 반환하는지 어떻게 알까요?

  • 표준 라이브러리 API 문서를 찾아보거나, 컴파일러에게 물어볼 수 있습니다!

  • File::open 함수의 반환 타입이 Result<T, E>

  • 여기서 제네릭 파라미터 T는 성공값의 타입인 std::fs::File로 채워져 있는데,

  • 이것은 파일 핸들입니다.

  • 에러에 사용되는 E의 타입은 std::io::Error입니다.

  • 이 반환 타입은 File::open을 호출하는 것이 성공하여 우리가 읽거나 쓸 수 있는 파일 핸들을 반환해 줄 수도 있다는 뜻입니다.

    • 함수 호출은 또한 실패할 수도 있습니다:
    • 예를 들면 파일이 존재하지 않거나 파일에 접근할 권한이 없을지도 모릅니다.
  • File::open 함수는 우리에게 성공했는지 혹은 실패했는지를 알려주면서 동시에 파일 핸들이나 에러 정보 둘 중 하나를 우리에게 제공할 방법을 가질 필요가 있습니다.

  • 바로 이러한 정보가 Result 열거형이 전달하는 것과 정확히 일치합니다.

  • File::open이 성공한 경우, 변수 f가 가지게 될 값은 파일 핸들을 담고 있는 Ok 인스턴스가 될 것입니다.

  • 실패한 경우, f의 값은 발생한 에러의 종류에 대한 더 많은 정보를 가지고 있는 Err의 인스턴스가 될 것입니다.

use std::fs::File;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => {
panic!("There was a problem opening the file: {:?}", error)
},
};
}

Listing 9-4: match 표현식을 사용하여 발생 가능한 Result variant들을 처리하기

  • Option 열거형과 같이 Result 열거형과 variant들은 프렐루드(prelude)로부터 가져와진다는 점을 기억하세요. ??
  • 따라서 match의 각 경우에 대해서 Ok와 Err 앞에 Result::를 특정하지 않아도 됩니다.

여기서 우리는 러스트에게 결과가 Ok일 때에는 Ok variant로부터 내부의 file 값을 반환하고, 이 파일 핸들 값을 변수 f에 대입한다고 말해주고 있습니다.

match 이후에는 읽거나 쓰기 위해 이 파일 핸들을 사용할 수 있습니다.

서로 다른 에러에 대해 매칭하기

  • Listing 9-3의 코드는 File::open이 실패한 이유가 무엇이든 간에 panic!을 일으킬 것입니다.
  • 대신 우리가 원하는 것은 실패 이유에 따라 다른 행동을 취하는 것입니다:
  • 파일이 없어서 File::open이 실패한 것이라면, 새로운 파일을 만들어서 핸들을 반환하고 싶습니다.
  • 만일 그 밖의 이유로 File::open이 실패한 거라면, 예를 들어 파일을 열 권한이 없어서라면, Listing 9-4에서 했던 것과 마찬가지로 panic!을 일으키고 싶습니다.
use std::fs::File;
use std::io::ErrorKind;

fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(ref error) if error.kind() == ErrorKind::NotFound => {
match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => {
panic!(
"Tried to create file but there was a problem: {:?}",
e
)
},
}
},
Err(error) => {
panic!(
"There was a problem opening the file: {:?}",
error
)
},
};
}
  • Err variant 내에 있는 File::open이 반환하는 값의 타입은 io::Error인데, 이는 표준 라이브러리에서 제공하는 구조체입니다.
  • 이 구조체는 kind 메소드를 제공하는데 이를 호출하여 io::ErrorKind값을 얻을 수 있습니다.
  • io::ErrorKind는 io 연산으로부터 발생할 수 있는 여러 종류의 에러를 표현하는 variant를 가진, 표준 라이브러리에서 제공하는 열거형입니다.
  • 우리가 사용하고자 하는 variant는 ErrorKind::NotFound인데, 이는 열고자 하는 파일이 아직 존재하지 않음을 나타냅니다.
  • 조건문 if error.kind() == ErrorKind::NotFound는 매치 가드(match guard) 라고 부릅니다:
  • 패턴에는 ref가 필요하며 그럼으로써 error가 가드 조건문으로 소유권 이동이 되지 않고 그저 참조만 됩니다.
    • 패턴 내에서 참조자를 얻기 위해 &대신 ref이 사용되는 이유는 18장에서 자세히 다룰 것입니다.
    • 짧게 설명하면, &는 참조자를 매치하고 그 값을 제공하지만, ref는 값을 매치하여 그 참조자를 제공합니다.
  • 매치 가드 내에서 확인하고자 하는 조건문은 error.kind()에 의해 반환된 값이 ErrorKind 열거형의 NotFound variant인가 하는 것입니다.
  • 바깥쪽 match의 마지막 갈래는 똑같이 남아서, 파일을 못 찾는 에러 외에 다른 어떤 에러에 대해서도 패닉을 일으킵니다.

에러가 났을 때 패닉을 위한 숏컷: unwrap과 expect

  • match의 사용은 충분히 잘 동작하지만, 살짝 장황하기도 하고 의도를 항상 잘 전달하는 것도 아닙니다.
  • Result<T, E> 타입은 다양한 작업을 하기 위해 정의된 수많은 헬퍼 메소드를 가지고 있습니다.
  • 그 중 하나인 unwrap 이라 부르는 메소드는 Listing 9-4에서 작성한 match 구문과 비슷한 구현을 한 숏컷 메소드입니다.
  • 만일 Result 값이 Ok variant라면, unwrap은 Ok 내의 값을 반환할 것입니다.
  • 만일 Result가 Err variant라면, unwrap은 우리를 위해 panic! 매크로를 호출할 것입니다. 아래에 unwrap이 작동하는 예가 있습니다:
use std::fs::File;
fn main() {
let f = File::open("hello.txt").unwrap();
}

또 다른 메소드인 expect는 unwrap과 유사한데, 우리가 panic! 에러 메세지를 선택할 수 있게 해줍니다. unwrap대신 expect를 이용하고 좋은 에러 메세지를 제공하는 것은 여러분의 의도를 전달해주고 패닉의 근원을 추적하는 걸 쉽게 해 줄 수 있습니다. expect의 문법은 아래와 같이 생겼습니다:

use std::fs::File;

fn main() {
let f = File::open("hello.txt").expect("Failed to open hello.txt");
}
  • expect는 unwrap과 같은 식으로 사용됩니다:
    • 파일 핸들을 리턴하거나 panic! 매크로를 호출하는 것이죠.
  • expect가 panic! 호출에 사용하는 에러 메세지는 unwrap이 사용하는 기본 panic! 메세지보다는 expect에 넘기는 파라미터로 설정될 것입니다.
  • 만일 우리가 여러 군데에 unwrap을 사용하면, 정확히 어떤 unwrap이 패닉을 일으켰는지 찾기에 좀 더 많은 시간이 걸릴 수 있는데, 그 이유는 패닉을 호출하는 모든 unwrap이 동일한 메세지를 출력하기 때문입니다.

에러 전파하기

실패할지도 모르는 무언가를 호출하는 구현을 가진 함수를 작성할 때, 이 함수 내에서 에러를 처리하는 대신, 에러를 호출하는 코드 쪽으로 반환하여 그쪽에서 어떻게 할지 결정하도록 할 수 있습니다.

이는 에러 전파하기로 알려져 있으며, 에러가 어떻게 처리해야 좋을지 좌우해야 할 상황에서, 여러분의 코드 내용 내에서 이용 가능한 것들보다 더 많은 정보와 로직을 가지고 있을 수도 있는 호출하는 코드 쪽에 더 많은 제어권을 줍니다.

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
let f = File::open("hello.txt");

let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};

let mut s = String::new();

match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(e) => Err(e),
}
}

Listing 9-6: match를 이용하여 호출 코드 쪽으로 에러를 반환하는 함수

함수의 반환 타입부터 먼저 살펴봅시다:

  • Result<String, io::Error>. 이는 함수가 Result<T, E> 타입의 값을 반환하는데 제네릭 파라미터 T는 구체적 타입(concrete type)인 String로 채워져 있고, 제네릭 타입 E는 구체적 타입인 io::Error로 채워져 있습니다.
  • 만일 이 함수가 어떤 문제 없이 성공하면, 함수를 호출한 코드는 String을 담은 값을 받을 것입니다
    • 이 함수가 파일로부터 읽어들인 사용자 이름이겠지요
  • . 만일 어떤 문제가 발생한다면, 이 함수를 호출한 코드는 문제가 무엇이었는지에 대한 더 많은 정보를 담고 있는 io::Error의 인스턴스를 담은 Err 값을 받을 것입니다.
    • 이 함수의 반환 타입으로서 io::Error를 선택했는데,
    • 그 이유는 우리가 이 함수 내부에서 호출하고 있는 실패 가능한 연산 두 가지가 모두 이 타입의 에러 값을 반환하기 때문입니다:
    • File::open 함수와 read_to_string 메소드 말이죠.
  1. 함수의 본체는 File::open 함수를 호출하면서 시작합니다.
  2. 그다음에는 Listing 9-4에서 본 match와 유사한 식으로 match을 이용해서 Result 값을 처리하는데, Err 경우에 panic!을 호출하는 대신 이 함수를 일찍 끝내고 File::open으로부터의 에러 값을 마치 이 함수의 에러 값인 것처럼 호출하는 쪽의 코드에게 전달합니다.
  3. 만일 File::open이 성공하면, 파일 핸들을 f에 저장하고 계속합니다.
  4. 그 뒤 변수 s에 새로운 String을 생성하고 파일의 콘텐츠를 읽어 s에 넣기 위해 f에 있는 파일 핸들의 read_to_string 메소드를 호출합니다.
  5. File::open가 성공하더라도 read_to_string 메소드가 실패할 수 있기 때문에 이 함수 또한 Result를 반환합니다.
  6. 따라서 이 Result를 처리하기 위해서 또 다른 match가 필요합니다:
  7. 만일 read_to_string이 성공하면, 우리의 함수가 성공한 것이고, 이제 s 안에 있는 파일로부터 읽어들인 사용자 이름을 Ok에 싸서 반환합니다.
  8. 만일 read_to_string이 실패하면, File::open의 반환값을 처리했던 match에서 에러값을 반환하는 것과 같은 방식으로 에러 값을 반환합니다.
  9. 하지만 여기서는 명시적으로 return이라 말할 필요는 없는데, 그 이유는 이 함수의 마지막 표현식이기 때문입니다.
  10. 그러면 이 코드를 호출하는 코드는 사용자 이름을 담은 Ok 값 혹은 io::Error를 담은 Err 값을 얻는 처리를 하게 될 것입니다.
  • 호출하는 코드가 이 값을 가지고 어떤 일을 할 것인지 우리는 알지 못합니다.
  • 만일 그쪽에서 Err 값을 얻었다면, 예를 들면 panic!을 호출하여 프로그램을 종료시키는 선택을 할 수도 있고, 기본 사용자 이름을 사용할 수도 있으며, 혹은 파일이 아닌 다른 어딘가에서 사용자 이름을 찾을 수도 있습니다.

러스트에서 에러를 전파하는 패턴은 너무 흔하여 러스트에서는 이를 더 쉽게 해주는 물음표 연산자 ?를 제공합니다.

에러를 전파하기 위한 숏컷: ?

Listing 9-7은 Listing 9-6과 같은 기능을 가진 read_username_from_file의 구현을 보여주는데, 다만 이 구현은 물음표 연산자를 이용하고 있습니다:

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
  • 새로운 String을 만들어 s에 넣는 부분을 함수의 시작 부분으로 옮겼습니다;
  • 이 부분은 달라진 것이 없습니다. f 변수를 만드는 대신, File::open("hello.txt")?의 결과 바로 뒤에 read_to_string의 호출을 연결시켰습니다.
  • read_to_string 호출의 끝에는 여전히 ?가 남아있고, File::open과 read_to_string이 모두 에러를 반환하지 않고 성공할 때 s 안의 사용자 이름을 담은 Ok를 여전히 반환합니다.
  • 함수의 기능 또한 Lsting 9-6과 Listing 9-7의 것과 동일

?는 Result를 반환하는 함수에서만 사용될 수 있습니다

  • ?는 Result 타입을 반환하는 함수에서만 사용이 가능한데, 이것이 Listing 9-6에 정의된 match 표현식과 동일한 방식으로 동작하도록 정의되어 있기 때문입니다.
  • Result 반환 타입을 요구하는 match 부분은 return Err(e)이며, 따라서 함수의 반환 타입은 반드시 이 return과 호환 가능한 Result가 되어야 합니다.
  • 여러분은 ?를 사용하여 호출하는 코드에게 잠재적으로 에러를 전파하는 대신 match나 Result에서 제공하는 메소드들 중 하나를 사용하여 이를 처리할 필요가 있을 것입니다.

panic!이냐, panic!이 아니냐, 그것이 문제로다

  • 코드가 패닉을 일으킬 때는 복구할 방법이 없습니다
  • 그렇다면 여러분은 여러분의 코드를 호출하는 코드를 대신하여 현 상황은 복구 불가능한 것이라고 결정을 내리는 겁니다.
  • 여러분이 Result 값을 반환하는 선택을 한다면, 호출하는 코드에게 결단을 내려주기보다는 옵션을 제공하는 것입니다.
  • 그들은 그들의 상황에 적합한 방식으로 복구를 시도할 수도 있고, 혹은 현재 상황의 Err은 복구 불가능하다고 사실상 결론을 내려서 panic!을 호출하여 여러분이 만든 복구 가능한 에러를 복구 불가능한 것으로 바꿔놓을 수도 있습니다.
  • 그러므로, 여러분이 실패할지도 모르는 함수를 정의할 때는 Result을 반환하는 것이 기본적으로 좋은 선택입니다.
  • 몇 가지 상황에서는 Result를 반환하는 대신 패닉을 일으키는 코드를 작성하는 것이 더 적합하지만, 덜 일반적입니다.

예제, 프로토타입 코드, 그리고 테스트는 전부 패닉을 일으켜도 완전 괜찮은 곳입니다

여러분이 어떤 개념을 그려내기 위한 예제를 작성 중이라면, 강건한 에러 처리 코드를 예제 안에 넣는 것은 또한 예제를 덜 깨끗하게 만들 수 있습니다.

컴파일러보다 여러분이 더 많은 정보를 가지고 있을 때

Result가 Ok 값을 가지고 있을 거라 확신할 다른 논리를 가지고 있지만, 그 논리가 컴파일러에 의해 이해할 수 있는 것이 아닐 때라면, unwrap을 호출하는 것이 또한 적절할 수 있습니다.

use std::net::IpAddr;

let home = "127.0.0.1".parse::<IpAddr>().unwrap();
  • 여기서는 하드코딩된 스트링을 파싱하여 IpAddr 인스턴스를 만드는 중입니다.
  • 우리는 127.0.0.1이 유효한 IP 주소임을 볼 수 있으므로, 여기서 unwrap을 사용하는 것은 허용됩니다.
  • 그러나, 하드코딩된 유효한 스트링을 갖고 있다는 것이 parse 메소드의 반환 타입을 변경해주지는 않습니다:
  • 우리는 여전히 Result 값을 갖게 되고, 컴파일러는 마치 Err variant가 나올 가능성이 여전히 있는 것처럼 우리가 Result를 처리하도록 할 것인데,
    • 그 이유는 이 스트링이 항상 유효한 IP 주소라는 것을 알 수 있을 만큼 컴파일러가 똑똑하지는 않기 때문입니다.
    • 만일 IP 주소 스트링이 프로그램 내에 하드코딩된 것이 아니라 사용자로부터 입력되었다면,
    • 그래서 실패할 가능성이 생겼다면, 우리는 대신 더 강건한 방식으로 Result를 처리할 필요가 분명히 있습니다.

에러 처리를 위한 가이드라인

여러분의 코드가 결국 나쁜 상태에 처하게 될 가능성이 있을 때는 여러분의 코드에 panic!을 넣는 것이 바람직합니다.

이 글에서 말하는 나쁜 상태란 어떤 가정, 보장, 계약, 혹은 불변성이 깨질 때를 뜻하는 것으로, 이를테면 유효하지 않은 값이나 모순되는 값, 혹은 찾을 수 없는 값이 여러분의 코드를 통과할 경우를 말합니다

만일 어떤 사람이 여러분의 코드를 호출하고 타당하지 않은 값을 집어넣었다면, panic!을 써서 여러분의 라이브러리를 사용하고 있는 사람에게 그들의 코드 내의 버그를 알려서 개발하는 동안 이를 고칠 수 있게끔 하는 것이 최선책일 수도 있습니다.

비슷한 식으로, 만일 여러분의 제어권을 벗어난 외부 코드를 호출하고 있고, 이것이 여러분이 고칠 방법이 없는 유효하지 않은 상태를 반환한다면, panic!이 종종 적합합니다.

나쁜 상태에 도달했지만, 여러분이 얼마나 코드를 잘 작성했든 간에 일어날 것으로 예상될 때라면 panic!을 호출하는 것보다 Result를 반환하는 것이 여전히 더 적합합니다.

이에 대한 예는 기형적인 데이터가 주어지는 파서나, 속도 제한에 달했음을 나타내는 상태를 반환하는 HTTP 요청 등을 포함합니다.

이러한 경우, 여러분은 이러한 나쁜 상태를 위로 전파하기 위해 호출자가 그 문제를 어떻게 처리할지를 결정할 수 있도록 하기 위해서 Result를 반환하는 방식으로 실패가 예상 가능한 것임을 알려줘야 합니다. panic!에 빠지는 것은 이러한 경우를 처리하는 최선의 방식이 아닐 것입니다.

  • 여러분의 코드가 어떤 값에 대해 연산을 수행할 때, 여러분의 코드는 해당 값이 유효한지를 먼저 검사하고, 만일 그렇지 않다면 panic!을 호출해야 합니다.
  • 이는 주로 안전상의 이유를 위한 것입니다:
  • 유효하지 않은 데이터 상에서 어떤 연산을 시도하는 것은 여러분의 코드를 취약점에 노출시킬 수 있습니다.
  • 이는 여러분이 범위를 벗어난 메모리 접근을 시도했을 경우 표준 라이브러리가 panic!을 호출하는 주된 이유입니다:
    • 현재의 데이터 구조가 소유하지 않은 메모리를 접근 시도하는 것은 흔한 보안 문제입니다.
    • 함수는 종종 계약을 갖고 있습니다: 입력이 특정 요구사항을 만족시킬 경우에만 함수의 행동이 보장됩니다.
    • 이 계약을 위반했을 때 패닉에 빠지는 것은 사리에 맞는데, 그 이유는 계약 위반이 언제나 호출자 쪽의 버그임을 나타내고, 이는 호출하는 코드가 명시적으로 처리하도록 하는 종류의 버그가 아니기 때문입니다.
    • 사실, 호출하는 쪽의 코드가 복구시킬 합리적인 방법은 없습니다:
    • 호출하는 프로그래머는 그 코드를 고칠 필요가 있습니다. 함수에 대한 계약은, 특히 계약 위반이 패닉의 원인이 될 때는, 그 함수에 대한 API 문서에 설명되어야 합니다.

하지만 여러분의 모든 함수 내에서 수많은 에러 검사를 한다는 것은 장황하고 짜증 날 것입니다. 다행스럽게도, 러스트의 타입 시스템이 (그리고 컴파일러가 하는 타입 검사 기능이) 여러분을 위해 수많은 검사를 해줄 수 있습니다. 여러분의 함수가 특정한 타입을 파라미터로 갖고 있다면, 여러분이 유효한 값을 갖는다는 것을 컴파일러가 이미 보장했음을 아는 상태로 여러분의 코드 로직을 진행할 수 있습니다.

  • 예를 들면, 만약 여러분이 Option이 아닌 어떤 타입을 갖고 있다면,
  • 여러분의 프로그램은 아무것도 아닌 것이 아닌 무언가를 갖고 있음을 예측합니다.
  • 그러면 여러분의 코드는 Some과 None variant에 대한 두 경우를 처리하지 않아도 됩니다:
  • 이는 분명히 값을 가지고 있는 하나의 경우만 있을 것입니다.
  • 여러분의 함수에 아무것도 넘기지 않는 시도를 하는 코드는 컴파일조차 되지 않을 것이고, 따라서 여러분의 함수는 그러한 경우에 대해서 런타임에 검사하지 않아도 됩니다. 또 다른 예로는 u32와 같은 부호 없는 정수를 이용하는 것이 있는데, 이는 파라미터가 절대 음수가 아님을 보장합니다.

유효성을 위한 커스텀 타입 생성하기

러스트의 타입 시스템을 이용하여 유효한 값을 보장하는 아이디어에서 한 발 더 나가서, 유효성을 위한 커스텀 타입을 생성하는 것을 살펴봅시다.

if 표현식은 우리의 값이 범위 밖에 있는지 혹은 그렇지 않은지 검사하고, 사용자에게 문제점을 말해주고, continue를 호출하여 루프의 다음 반복을 시작하고 다른 추측값을 요청해줍니다. if 표현식 이후에는, guess가 1과 100 사이의 값이라는 것을 아는 상태에서 guess와 비밀 숫자의 비교를 진행할 수 있습니다.

  • 하지만, 이는 이상적인 해결책이 아닙니다:
  • 만일 프로그램이 오직 1과 100 사이의 값에서만 동작하는 것이 전적으로 중요하고, 많은 함수가 이러한 요구사항을 가지고 있다면, 모든 함수 내에서 이렇게 검사를 하는 것은 지루할 것입니다.
  • 그리고 잠재적으로 성능에 영향을 줄 것입니다.

대신, 우리는 새로운 타입을 만들어서, 유효성 확인을 모든 곳에서 반복하는 것보다는 차라리 그 타입의 인스턴스를 생성하는 함수 내에 유효성 확인을 넣을 수 있습니다.

이 방식에서, 함수가 그 시그니처 내에서 새로운 타입을 이용하고 받은 값을 자신 있게 사용하는 것은 안전합니다. Listing 9-9는 new 함수가 1과 100 사이의 값을 받았을 때에만 인스턴스를 생성하는 Guess 타입을 정의하는 한 가지 방법을 보여줍니다:

pub struct Guess {
value: u32,
}
impl Guess {
pub fn new(value: u32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {}.", value);
}

Guess {
value
}
}

pub fn value(&self) -> u32 {
self.value
}
}

Listing 9-9: 1과 100 사이의 값일 때만 계속되는 Guess 타입

  1. 먼저 u32를 갖는 value라는 이름의 항목을 가진 Guess라는 이름의 구조체를 선언하였습니다.
  2. 그런 뒤 Guess 값의 인스턴스를 생성하는 new라는 이름의 연관 함수를 구현하였습니다.
    1. new 함수는 u32 타입의 값인 value를 파라미터를 갖고 Guess를 반환하도록 정의 되었습니다.
    2. new 함수의 본체에 있는 코드는 value가 1부터 100 사이의 값인지 확인하는 테스트를 합니다.
    3. 만일 value가 이 테스트에 통과하지 못하면 panic!을 호출하며, 이는 이 코드를 호출하는 프로그래머에게 고쳐야 할 버그가 있음을 알려주는데, 범위 밖의 value를 가지고 Guess를 생성하는 것은 Guess::new가 필요로 하는 계약을 위반하기 때문입니다.
    4. Guess::new가 패닉을 일으킬 수도 있는 조건은 공개된 API 문서 내에 다뤄져야 합니다;
    5. 여러분이 만드는 API 문서 내에서 panic!의 가능성을 가리키는 것에 대한 문서 관례는 14장에서 다룰 것입니다.
    6. 만일 value가 테스트를 통과한다면, value 항목을 value 파라미터로 설정한 새로운 Guess를 생성하여 이 Guess를 반환합니다.
  3. 다음으로, self를 빌리고, 파라미터를 갖지 않으며, u32를 반환하는 value라는 이름의 메소드를 구현했습니다.
    1. 이러한 종류 메소드를 종종 게터(getter) 라고 부르는데, 그 이유는 이런 함수의 목적이 객체의 항목으로부터 어떤 데이터를 가져와서 이를 반환하는 것이기 때문입니다.
    2. 이 공개 메소드는 Guess 구조체의 value 항목이 비공개이기 때문에 필요합니다.
    3. value 항목이 비공개라서 Guess 구조체를 이용하는 코드가 value를 직접 설정하지 못하도록 하는 것은 중요합니다:
    4. 모듈 밖의 코드는 반드시 Guess::new 함수를 이용하여 새로운 Guess의 인스턴스를 만들어야 하는데,
    5. 이는 Guess가 Guess::new 함수의 조건들을 확인한 적이 없는 value를 갖는 방법이 없음을 보장합니다.

그러면 파라미터를 가지고 있거나 오직 1에서 100 사이의 숫자를 반환하는 함수는 u32 보다는 Guess를 얻거나 반환하는 시그니처로 선언되고 더 이상의 확인이 필요치 않을 것입니다.

정리

  • 러스트의 에러 처리 기능은 여러분이 더 강건한 코드를 작성하는 데 도움을 주도록 설계되었습니다.
  • panic! 매크로는 여러분의 프로그램이 처리 불가능한 상태에 놓여 있음에 대한 신호를 주고 여러분이 유효하지 않거나 잘못된 값으로 계속 진행하는 시도를 하는 대신 실행을 멈추게끔 해줍니다.
  • Result 열거형은 러스트의 타입 시스템을 이용하여 여러분의 코드가 복구할 수 있는 방법으로 연산이 실패할 수도 있음을 알려줍니다.
  • 또한 Result를 이용하면 여러분의 코드를 호출하는 코드에게 잠재적인 성공이나 실패를 처리해야 할 필요가 있음을 알려줄 수 있습니다.
  • panic!과 Result를 적합한 상황에서 사용하는 것은 여러분의 코드가 불가피한 문제에 직면했을 때도 더 신뢰할 수 있도록 해줄 것입니다.

이제 표준 라이브러리가 Option과 Result 열거형을 가지고 제네릭을 사용하는 유용한 방식들을 보았으니, 제네릭이 어떤 식으로 동작하고 여러분의 코드에 어떻게 이용할 수 있는지에 대해 다음 장에서 이야기해 보겠습니다.

Chap.8 common-collections

· 60 min read
brown
FE developer

러스트의 표준 라이브러리에는 컬렉션이라 불리는 여러 개의 매우 유용한 데이터 구조들이 포함되어 있습니다.

대부분의 다른 데이터 타입들은 하나의 특정한 값을 나타내지만, 컬렉션은 다수의 값을 담을 수 있습니다.

내장된 배열(build-in array)와 튜플 타입과는 달리, 이 컬렉션들이 가리키고 있는 데이터들은 힙에 저장되는데, 이는 즉 데이터량이 컴파일 타임에 결정되지 않아도 되며 프로그램이 실행될 때 늘어나거나 줄어들 수 있다는 의미입니다.

각각의 컬렉션 종류는 서로 다른 용량과 비용을 가지고 있으며, 여러분의 현재 상황에 따라 적절한 컬렉션을 선택하는 것은 시간이 지남에 따라 발전시켜야 할 기술입니다.

이번 장에서는 러스트 프로그램에서 굉장히 자주 사용되는 세 가지 컬렉션에 대해 논의해 보겠습니다:

  • 벡터(vector) 는 여러 개의 값을 서로 붙어 있게 저장할 수 있도록 해줍니다.
  • 스트링(string) 은 문자(character)의 모음입니다. String 타입은 이전에 다루었지만, 이번 장에서는 더 깊이 있게 이야기해 보겠습니다.
  • 해쉬맵(hash map 은 어떤 값을 특정한 키와 연관지어 주도록 해줍니다. 이는 맵(map) 이라 일컫는 좀더 일반적인 데이터 구조의 특정한 구현 형태입니다.

표준 라이브러리가 제공해주는 다른 종류의 컬렉션에 대해 알고 싶으시면, the documentation를 봐 주세요.

벡터

우리가 보게될 첫번째 콜렉션은 벡터라고도 알려진 Vec<T>입니다.

  • 벡터는 메모리 상에 서로 이웃하도록 모든 값을 집어넣는 단일 데이터 구조 안에 하나 이상의 값을 저장하도록 해줍니다.
  • 벡터는 같은 타입의 값만을 저장할 수 있습니다.

새 벡터 만들기

비어있는 새 벡터를 만들기 위해서는, 아래의 Listing 8-1과 같이 Vec::new 함수를 호출해 줍니다:

let v: Vec<i32> = Vec::new();

  • 여기에 타입 명시(type annotation)를 추가한 것을 주목하세요.
  • 이 벡터에 어떠한 값도 집어넣지 않았기 때문에, 러스트는 우리가 저장하고자 하는 요소의 종류가 어떤 것인지 알지 못합니다.
  • 벡터는 제네릭(generic)을 이용하여 구현되었습니다;
    • 표준 라이브러리가 제공하는 Vec타입은 어떠한 종류의 값이라도 저장할 수 있다는 것,
    • 그리고 특정한 벡터는 특정한 타입의 값을 저장할 때, 이 타입은 꺾쇠 괄호(<>) 안에 적는다는 것만 알아두세요.
  • 일단 우리가 값을 집어넣으면 러스트는 우리가 저장하고자 하는 값의 타입을 대부분 유추할 수 있으므로, 좀 더 현실적인 코드에서는 이러한 타입 명시를 할 필요가 거의 없습니다.
  • 초기값들을 갖고 있는 Vec<T>을 생성하는 것이 더 일반적이며, 러스트는 편의를 위해 vec! 매크로를 제공합니다.
  • 이 매크로는 우리가 준 값들을 저장하고 있는 새로운 Vec을 생성합니다.
  • 123을 저장하고 있는 새로운 Vec<i32>을 생성할 것입니다:

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

Listing 8-2: 값을 저장하고 있는 새로운 벡터 생성하기

초기 i32 값들을 제공했기 때문에, 러스트는 vVec 타입이라는 것을 유추할 수 있으며, 그래서 타입 명시는 필요치 않습니다.

벡터 갱신하기

벡터를 만들고 여기에 요소들을 추가하기 위해서 push 메소드를 사용할 수 있습니다:

	let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);

Listing 8-3: push 메소드를 사용하여 벡터에 값을 추가하기

어떤 변수에 대해 그 변수가 담고 있는 값이 변경될 수 있도록 하려면, mut 키워드를 사용하여 해당 변수를 가변으로 만들어 줄 필요가 있습니다.

우리가 집어넣는 숫자는 모두 i32 타입이며, 러스트는 데이터로부터 이 타입을 추론하므로, 우리는 Vec<i32> 명시를 붙일 필요가 없습니다.

벡터를 드롭하는 것은 벡터의 요소들을 드롭시킵니다

struct와 마찬가지로, Listing 8-4에 달려있는 주석처럼 벡터도 스코프 밖으로 벗어났을 때 해제됩니다:

	{
let v = vec![1, 2, 3, 4];
// v를 가지고 뭔가 합니다
}
// <- v가 스코프 밖으로 벗어났고, 여기서 해제됩니다

벡터의 요소들 읽기

지금까지 벡터를 만들고, 갱신하고, 없애는 방법에 대해 알아보았으니, 벡터의 내용물을 읽어들이는 방법을 알아보는 것이 다음 단계로 좋아보입니다. 벡터 내에 저장된 값을 참조하는 두 가지 방법이 있습니다.

{
let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2];
let third: Option<&i32> = v.get(2);
}

Listing 8-5: 인덱스 문법 혹은 get 메소드를 사용하여 벡터 내의 아이템에 접근하기

두가지 세부사항을 주목하세요.

  • 첫번째로, 인덱스값 2를 사용하면 세번째 값이 얻어집니다:
    • 벡터는 0부터 시작하는 숫자로 인덱스됩니다.
  • 두번째로, 세번째 요소를 얻기 위해 두 가지 다른 방법이 사용되었습니다:
    • &와 []를 이용하여 참조자를 얻은 것과, get 함수에 인덱스를 파라미터로 넘겨서 Option<&T>를 얻은 것입니다.

러스트가 벡터 요소를 참조하는 두가지 방법을 제공하는 이유는 여러분이 벡터가 가지고 있지 않은 인덱스값을 사용하고자 했을 때 프로그램이 어떻게 동작할 것인지 여러분이 선택할 수 있도록 하기 위해서입니다.

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

let does_not_exist = &v[100];
let does_not_exist = v.get(100);

Listing 8-6: 5개의 요소를 가진 벡터에 100 인덱스에 있는 요소에 접근하기

  1. 이 프로그램을 실행하면, 첫번째의 [] 메소드는 panic!을 일으키는데, 이는 존재하지 않는 요소를 참조하기 때문입니다.

    이 방법은 여러분의 프로그램이 벡터의 끝을 넘어서는 요소에 접근하는 시도를 하면 프로그램이 죽게끔 하는 치명적 에러를 발생하도록 하기를 고려하는 경우 가장 좋습니다.

  2. get 함수에 벡터 범위를 벗어난 인덱스가 주어졌을 때는 패닉 없이 None이 반환됩니다. 보통의 환경에서 벡터의 범위 밖에 있는 요소에 접근하는 것이 종종 발생한다면 이 방법을 사용할만 합니다. 여러분의 코드는 우리가 6장에서 본 것과 같이 Some(&element) 혹은 None에 대해 다루는 로직을 갖추어야 합니다.

유효하지 않은 참조자

프로그램이 유효한 참조자를 얻을 때, 빌림 검사기(borrow checker)가 소유권 및 빌림 규칙을 집행하여 이 참조자와 벡터의 내용물로부터 얻은 다른 참조자들이 계속 유효하게 남아있도록 확실히 해줍니다.

같은 스코프 내에서 가변 참조자와 불변 참조자를 가질 수 없다는 규칙을 상기하세요.

이 규칙은 아래 예제에서도 적용되는데, Listing 8-7에서는 벡터의 첫번째 요소에 대한 불변 참조자를 얻은 뒤 벡터의 끝에 요소를 추가하고자 했습니다:

	let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);

// error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable

Listing 8-7의 코드는 동작을 해야만 할것처럼 보일 수도 있습니다:

  • 왜 첫번째 요소에 대한 참조자가 벡터 끝에 대한 변경을 걱정해야 하죠?
  • 이 에러에 대한 내막은 벡터가 동작하는 방법 때문입니다:
    • 새로운 요소를 벡터의 끝에 추가하는 것은
    • 새로 메모리를 할당하여 예전 요소를 새 공간에 복사하는 일을 필요로 할 수 있는데,
    • 이는 벡터가 모든 요소들을 붙여서 저장할 공간이 충분치 않는 환경에서 일어날 수 있습니다.
    • 이러한 경우, 첫번째 요소에 대한 참조자는 할당이 해제된 메모리를 가리키게 될 것입니다.
    • 빌림 규칙은 프로그램이 이러한 상황에 빠지지 않도록 해줍니다.

노트: Vec<T> 타입의 구현 세부사항에 대한 그밖의 것에 대해서는 https://doc.rust-lang.org/stable/nomicon/vec.html 에 있는 노미콘(The Nomicon)을 보세요:

벡터 내의 값들에 대한 반복처리

만일 벡터 내의 각 요소들을 차례대로 접근하고 싶다면, 하나의 값에 접근하기 위해 인덱스를 사용하는것 보다는, 모든 요소들에 대해 반복처리를 할 수 있습니다.

let v = vec![100, 32, 57];
for i in &v {
println!("{}", i);
}

Listing 8-8: for 루프를 이용한 요소들에 대한 반복작업을 통해 각 요소들을 출력하기

만일 모든 요소들을 변형시키길 원한다면 가변 벡터 내의 각 요소에 대한 가변 참조자로 반복작업을 할 수도 있습니다.

let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}

Listing 8-9: 벡터 내의 요소에 대한 가변 참조자로 반복하기

가변 참조자가 참고하고 있는 값을 바꾸기 위해서, i에 += 연산자를 이용하기 전에 역참조 연산자 (*)를 사용하여 값을 얻어야 합니다.

열거형을 사용하여 여러 타입을 저장하기

이 장의 시작 부분에서, 벡터는 같은 타입을 가진 값들만 저장할 수 있다고 이야기했습니다. 이는 불편할 수 있습니다; 다른 타입의 값들에 대한 리스트를 저장할 필요가 있는 상황이 분명히 있지요. 다행히도, 열거형의 variant는 같은 열거형 타입 내에 정의가 되므로, 백터 내에 다른 타입의 값들을 저장할 필요가 있다면 열거형을 정의하여 사용할 수 있습니다!

예를 들어, 스프레드시트의 행으로부터 값들을 가져오고 싶은데, 여기서 어떤 열은 정수를, 어떤 열은 실수를, 어떤 열은 스트링을 갖고 있다고 해봅시다. 우리는 다른 타입의 값을 가지는 variant가 포함된 열거형을 정의할 수 있고, 모든 열거형 variant들은 해당 열거형 타입, 즉 같은 타입으로 취급될 것입니다. 따라서 우리는 궁극적으로 다른 타입을 담은 열거형 값에 대한 벡터를 생성할 수 있습니다. Listing 8-10에서 이를 보여주고 있습니다:

	enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];

Listing 8-10: 열거형을 정의하여 벡터 내에 다른 타입의 데이터를 담을 수 있도록 하기

러스트가 컴파일 타임에 벡터 내에 저장될 타입이 어떤 것인지 알아야할 필요가 있는 이유는 각 요소를 저장하기 위해 얼만큼의 힙 메모리가 필요한지 알기 위함입니다.

  • 부차적인 이점은 이 백터에 허용되는 타입에 대해 명시적일 수 있다는 점입니다.
  • 만일 러스트가 어떠한 타입이든 담을수 있는 벡터를 허용한다면, 벡터 내의 각 요소마다 수행되는 연산에 대해 하나 혹은 그 이상의 타입이 에러를 야기할 수도 있습니다.

열거형과 match 표현식을 사용한다는 것은 6장에서 설명한 바와 같이 러스트가 컴파일 타임에 모든 가능한 경우에 대해 처리한다는 것을 보장해준다는 의미입니다.

만약 프로그램을 작성할 때 여러분의 프로그램이 런타임에 벡터에 저장하게 될 타입의 모든 경우를 알지 못한다면, 열거형을 이용한 방식은 사용할 수 없을 것입니다.

대신 트레잇 객체(trait object)를 이용할 수 있는데, 이건 17장에서 다루게 될 것입니다.

지금까지 벡터를 이용하는 가장 일반적인 방식 중 몇가지에 대해 논의했는데, 표준 라이브러리의 Vec에 정의된 수많은 유용한 메소드들이 있으니 API 문서를 꼭 살펴봐 주시기 바랍니다. 예를 들면, push에 더해서, pop 메소드는 제일 마지막 요소를 반환하고 지워줍니다. 다음 콜렉션 타입인 String으로 넘어갑시다!

스트링

새로운 러스트인들은 흔히들 스트링 부분에서 막히는데 이는 세 가지 개념의 조합으로 인한 것입니다:

  • 가능한 에러를 꼭 노출하도록 하는 러스트의 성향,
  • 많은 프로그래머의 예상보다 더 복잡한 데이터 구조인 스트링,
  • 그리고 UTF-8입니다. 다른 언어들을 사용하다 왔을 때 이 개념들의 조합이 러스트의 스트링을 어려운 것처럼 보이게 합니다.

스트링이 컬렉션 장에 있는 이유는 스트링이 바이트의 컬렉션 및 이 바이트들을 텍스트로 통역할때 유용한 기능을 제공하는 몇몇 메소드로 구현되어 있기 때문입니다.

이번 절에서는 생성, 갱신, 값 읽기와 같은 모든 컬렉션 타입이 가지고 있는, String에서의 연산에 대해 이야기 해보겠습니다.

또한 String을 다른 컬렉션들과 다르게 만드는 부분, 즉 사람과 컴퓨터가 String 데이터를 통역하는 방식 간의 차이로 인해 생기는 String 인덱싱의 복잡함을 논의해보겠습니다.

스트링이 뭔가요?

러스트는 핵심 언어 기능 내에서 딱 한가지 스트링 타입만 제공하는데, 이는 바로 스트링 슬라이스인 str이고, 이것의 참조자 형태인 &str을 많이 봤죠.

  • 이는 다른 어딘가에 저장된 UTF-8로 인코딩된 스트링 데이터의 참조자입니다.
  • 예를 들어, 스트링 리터럴은 프로그램의 바이너리 출력물 내에 저장되어 있으며, 그러므로 스트링 슬라이스입니다.

String 타입은 핵심 언어 기능 내에 구현된 것이 아니고 러스트의 표준 라이브러리를 통해 제공되며, 커질 수 있고, 가변적이며, 소유권을 갖고 있고, UTF-8로 인코딩된 스트링 타입입니다.

러스트인들이 스트링에 대해 이야기할 때, 그들은 보통 String과 스트링 슬라이스 &str 타입 둘 모두를 이야기한 것이지, 이들 중 하나를 뜻한 것은 아닙니다.

이번 절은 대부분 String에 관한 것이지만, 두 타입 모두 러스트 표준 라이브러리에서 매우 많이 사용되며 String과 스트링 슬라이스 모두 UTF-8로 인코딩되어 있습니다.

또한 러스트 표준 라이브러리는 OsStringOsStrCString, 그리고 CStr과 같은 몇가지 다른 스트링 타입도 제공합니다. 심지어 어떤 라이브러리 크레이트들은 스트링 데이터를 저장하기 위해 더 많은 옵션을 제공할 수 있습니다. *String/*Str이라는 작명과 유사하게, 이들은 종종 소유권이 있는 타입과 이를 빌린 변형 타입을 제공하는데, 이는 String/&str과 비슷합니다. 이러한 스트링 타입들은, 예를 들면 다른 종류의 인코딩이 된 텍스트를 저장하거나 다른 방식으로 메모리에 저장될 수 있습니다. 여기서는 이러한 다른 스트링 타입은 다루지 않겠습니다; 이것들을 어떻게 쓰고 어떤 경우에 적합한지에 대해 알고 싶다면 각각의 API 문서를 확인하시기 바랍니다.

새로운 스트링 생성하기

Vec에서 쓸 수 있는 많은 연산들이 String에서도 마찬가지로 똑같이 쓰일 수 있는데, new 함수를 이용하여 스트링을 생성하는 것으로 아래의 Listing 8-11과 같이 시작해봅시다:

let mut s = String::new();

Listing 8-11: 비어있는 새로운 String 생성하기

  • 이 라인은 우리가 어떤 데이터를 담아둘 수 있는 s라는 빈 스트링을 만들어 줍니다.
  • 종종 우리는 스트링에 담아두고 시작할 초기값을 가지고 있을 것입니다.
  • 그런 경우, to_string 메소드를 이용하는데, 이는 Display 트레잇이 구현된 어떤 타입이든 사용 가능하며, 스트링 리터럴도 이 트레잇을 구현하고 있습니다.
	let data = "initial contents";
let s = data.to_string();
// the method also works on a literal directly:
let s = "initial contents".to_string();

Listing 8-12: to_string 메소드를 사용하여 스트링 리터럴로부터 String 생성하기

이 코드는 initial contents를 담고 있는 스트링을 생성합니다.

또한 스트링 리터럴로부터 String을 생성하기 위해서 String::from 함수를 이용할 수도 있습니다. Listing 8-13의 코드는 to_string을 사용하는 Listing 8-12의 코드와 동일합니다:

스트링이 UTF-8로 인코딩되었음을 기억하세요. 즉, 아래의 Listing 8-14에서 보는 것처럼 우리는 인코딩된 어떤 데이터라도 포함시킬 수 있습니다:

	let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שָׁלוֹם");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");

위의 모두가 유효한 String 값입니다.

스트링 갱신하기

String은 크기가 커질 수 있으며 이것이 담고 있는 내용물은 Vec의 내용물과 마찬가지로 더 많은 데이터를 집어넣음으로써 변경될 수 있습니다.

추가적으로, + 연산자나 format! 매크로를 사용하여 편리하게 String 값들을 서로 접합(concatenation)할 수 있습니다.

push_str과 push를 이용하여 스트링 추가하기

스트링 슬라이스를 추가하기 위해 push_str 메소드를 이용하여 String을 키울 수 있습니다:

	let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(&s2);
println!("s2 is {}", s2);

만일 push_str 함수가 s2의 소유권을 가져갔다면, 마지막 줄에서 그 값을 출력할 수 없었을 것입니다. 하지만, 이 코드는 우리가 기대했던 대로 작동합니다!

+ 연산자나 format! 매크로를 이용한 접합

종종 우리는 가지고 있는 두 개의 스트링을 조합하고 싶어합니다. 한 가지 방법은 아래 Listing 8-18와 같이 + 연산자를 사용하는 것입니다:

	let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1은 여기서 이동되어 더이상 쓸 수 없음을 유의하세요

+ 연산자를 사용하여 두 String 값을 하나의 새로운 String 값으로 조합하기

위의 코드 실행 결과로서, 스트링 s3는 Hello, world!를 담게 될 것입니다. s1이 더하기 연산 이후에 더이상 유효하지 않은 이유와 s2의 참조자가 사용되는 이유는 + 연산자를 사용했을 때 호출되는 함수의 시그니처와 맞춰야 하기 때문입니다 + 연산자는 add 메소드를 사용하는데, 이 메소드의 시그니처는 아래처럼 생겼습니다:

fn add(self, s: &str) -> String {

  • 이는 표준 라이브러리에 있는 정확한 시그니처는 아닙니다.

  • 첫번째로, s2는 &를 가지고 있는데,

  • 이는 add 함수의 s 파라미터 때문에 첫번째 스트링에 두번째 스트링의 참조자를 더하고 있음을 뜻합니다:

  • 우리는 String에 &str만 더할 수 있고, 두 String을 더하지는 못합니다.

    • 하지만, 잠깐만요 - &s2의 타입은 &String이지, add의 두번째 파라미터에 명시한 것 처럼 &str은 아니죠.
    • &s2를 add 호출에 사용할 수 있는 이유는 &String 인자가 &str로 강제될 수 있기 때문입니다.
    • add 함수가 호출되면, 러스트는 역참조 강제(deref coercion) 라 불리는 무언가를 사용하는데, 이는 add 함수내에서 사용되는 &s2가 &s2[..]로 바뀌는 것으로 생각할 수 있도록 해줍니다.
    • 역참조 강제에 대한 것은 15장에서 다룰 것입니다.
    • add가 파라미터의 소유권을 가져가지는 않으므로, s2는 이 연산 이후에도 여전히 유효한 String일 것입니다.
  • 두번째로, 시그니처에서 add가 self의 소유권을 가져가는 것을 볼 수 있는데, 이는 self가 &를 안 가지고 있기 때문입니다.

  • 즉 Listing 8-18의 예제에서 s1이 add 호출로 이동되어 이후에는 더 이상 유효하지 않을 것이라는 의미입니다.

  • 따라서 let s3 = s1 + &s2;가 마치 두 스트링을 복사하여 새로운 스트링을 만들 것처럼 보일지라도, 실제로 이 구문은 s1의 소유권을 가져다가 s2의 내용물의 복사본을 추가한 다음, 결과물의 소유권을 반환합니다.

  • 달리 말하면, 이 구문은 여러 복사본을 만드는 것처럼 보여도 그렇지 않습니다:

  • 이러한 구현은 복사보다 더 효율적입니다.

s에 tic-tac-toe을 설정합니다. format! 매크로는 println!과 똑같은 방식으로 작동하지만, 스크린에 결과를 출력하는 대신 결과를 담은 String을 반환해줍니다.

format!을 이용한 버전이 훨씬 읽기 쉽고, 또한 어떠한 파라미터들의 소유권도 가져가지 않습니다.

	let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = s1 + "-" + &s2 + "-" + &s3;
let s = format!("{}-{}-{}", s1, s2, s3);

스트링 내부의 인덱싱

다른 많은 프로그래밍 언어들에서, 인덱스를 이용한 참조를 통해 스트링 내부의 개별 문자들에 접근하는 것은 유효하고 범용적인 연산에 속합니다.

그러나 러스트에서 인덱싱 문법을 이용하여 String의 부분에 접근하고자 하면 에러를 얻게 됩니다.

	let s1 = String::from("hello");
let h = s1[0];
// note: the type `std::string::String` cannot be indexed by `_`
  • 에러와 노트 부분이 이야기해 줍니다: 러스트 스트링은 인덱싱을 지원하지 않는다고.
  • 그렇지만 왜 안되는 걸까요?
  • 이 질문에 답하기 위해서는 러스트가 어떻게 스트링을 메모리에 저장하는지에 관하여 살짝 이야기해야 합니다.

내부적 표현

String은 Vec<u8>을 감싼 것입니다.

Listing 8-14에서 보았던 몇가지 적절히 인코딩된 UTF-8 예제 스트링을 살펴봅시다.

// 첫번째로, 이것입니다:
let len = String::from("Hola").len();
  • 이 경우, len은 4가 되는데,
  • 이는 스트링 “Hola”를 저장하고 있는 Vec이 4바이트 길이라는 뜻입니다.
  • UTF-8로 인코딩되면 각각의 글자들이 1바이트씩 차지한다는 것이죠.
  • 그런데 아래 예제는 어떨까요?

let len = String::from("Здравствуйте").len();

  • 이 스트링의 길이가 얼마인지 묻는다면, 여러분은 12라고 답할런지도 모릅니다.
  • 그러나 러스트의 대답은 24입니다.
  • 이는 “Здравствуйте”를 UTF-8로 인코딩된 바이트들의 크기인데, 각각의 유니코드 스칼라 값이 저장소의 2바이트를 차지하기 때문입니다.
  • 따라서, 스트링의 바이트들 안의 인덱스는 유효한 유니코드 스칼라 값과 항상 대응되지는 않을 것입니다.

이를 보여주기 위해, 다음과 같은 유효하지 않은 러스트 코드를 고려해 보세요:

let hello = "Здравствуйте";
let answer = &hello[0];
  • answer의 값은 무엇이 되어야 할까요?
  • 첫번째 글자인 З이 되어야 할까요?
    • UTF-8로 인코딩될 때, З의 첫번째 바이트는 208이고, 두번째는 151이므로,
    • answer는 사실 208이 되어야 하지만, 208은 그 자체로는 유효한 문자가 아닙니다.
    • 208을 반환하는 것은 사람들이 이 스트링의 첫번째 글자를 요청했을 경우 사람들이 기대하는 것이 아닙니다;
    • 하지만 그게 러스트가 인덱스 0에 가지고 있는 유일한 데이터죠.
    • 바이트 값을 반환하는 것은 아마도 유저들이 원하는 것이 아닐 것입니다.
    • 심지어는 라틴 글자들만 있을 때도요: &"hello"[0]는 h가 아니라 104를 반환합니다.
  • 기대치 않은 값을 반환하고 즉시 발견하기 힘들지도 모를 버그를 야기하는 것을 방지하기 위해,
  • 러스트는 이러한 코드를 전혀 컴파일하지 않고 이러한 오해들을 개발 과정 내에서 일찌감치 방지합니다.

바이트와 스칼라 값과 문자소 클러스터(Grapheme cluster)! 이런!

UTF-8에 대한 또다른 지점은, 실제로는 러스트의 관점에서 문자열을 보는 세 가지의 의미있는 방식이 있다는 것입니다:

바이트, 스칼라 값, 그리고 문자소 클러스터(글자라고 부르는 것과 가장 근접한 것)입니다.

데바가나리 글자로 쓰여진 힌디어 “नमस्ते”를 보면, 이것은 궁극적으로 아래와 같이 u8 값들의 Vec으로서 저장됩니다: [224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]

이건 18바이트이고 컴퓨터가 이 데이터를 궁극적으로 저장하는 방법입니다. 만일 우리가 이를 유니코드 스칼라 값, 즉 러스트의 char 타입인 형태로 본다면, 아래와 같이 보이게 됩니다:

['न', 'म', 'स', '्', 'त', 'े']

  • 여섯개의 char 값이 있지만, 네번쨰와 여섯번째는 글자가 아닙니다:
  • 그 자체로는 이해할 수 없는 발음 구별 부호입니다.
  • 마지막으로, 만일 이를 문자소 클러스터로서 본다면, 사람들이 발음할 이 힌디 단어를 구성하는 네 글자를 얻습니다:

["न", "म", "स्", "ते"]

러스트는 컴퓨터가 저장하는 가공되지 않은(raw) 스트링을 번역하는 다른 방법을 제공하여, 데이터가 담고 있는 것이 어떤 인간의 언어든 상관없이 각각의 프로그램이 필요로 하는 통역방식을 선택할 수 있도록 합니다.

  • 러스트가 String을 인덱스로 접근하여 문자를 얻지 못하도록 하는 마지막 이유는 인덱스 연산이 언제나 상수 시간(O(1))에 실행될 것으로 기대받기 때문입니다.
  • 그러나 String을 가지고 그러한 성능을 보장하는 것은 불가능한데, 그 이유는 러스트가 스트링 내에 얼마나 많은 유효 문자가 있는지 알아내기 위해 내용물의 시작 지점부터 인덱스로 지정된 곳까지 훑어야 하기 때문입니다.

스트링 슬라이싱하기

스트링 인덱싱의 리턴 타입이 어떤 것이 (바이트 값인지, 캐릭터인지, 문자소 클러스터인지, 혹은 스트링 슬라이스인지) 되어야 하는지 명확하지 않기 때문에 스트링의 인덱싱은 종종 나쁜 아이디어가 됩니다.

따라서, 여러분이 스트링 슬라이스를 만들기 위해 정말로 인덱스를 사용하고자 한다면 러스트는 좀 더 구체적으로 지정하도록 요청합니다.

여러분의 인덱싱을 더 구체적으로 하고 스트링 슬라이스를 원한다는 것을 가리키기 위해서, []에 숫자 하나를 사용하는 인덱싱보다, []와 범위를 사용하여 특정 바이트들이 담고 있는 스트링 슬라이스를 만들 수 있습니다

	let hello = "Здравствуйте";
let s = &hello[0..4];

여기서 s는 스트링의 첫 4바이트를 담고 있는 &str가 될 것입니다. 앞서 우리는 이 글자들이 각각 2바이트를 차지한다고 언급했으므로, 이는 s가 “Зд”이 될 것이란 뜻입니다.

  • 만약에 &hello[0..1]라고 했다면 어떻게 될까요?
  • 답은 다음과 같습니다:
  • 러스트는 벡터 내에 유효하지 않은 인덱스에 접근했을 때와 동일한 방식으로 런타임에 패닉을 발생시킬 것입니다.

스트링 내에서 반복적으로 실행되는 메소드

다행히도, 스트링의 요소에 접근하는 다른 방법이 있습니다.

만일 개별적인 유니코드 스칼라 값에 대한 연산을 수행하길 원한다면, 가장 좋은 방법은 chars 메소드를 이용하는 것입니다. chars를 “नमस्ते”에 대해 호출하면 char타입의 6개의 값으로 나누어 반환하며, 여러분은 각각의 요소에 접근하기 위해 이 결과값에 대해 반복(iterate)할 수 있습니다

for c in "नमस्ते".chars() {
println!("{}", c);
}
// न म स ् त े

bytes 메소드는 가공되지 않은 각각의 바이트를 반환하는데, 여러분의 문제 범위에 따라 적절할 수도 있습니다:

for b in "नमस्ते".bytes() {
println!("{}", b);
}
// 224 164 168 224

하지만 유효한 유니코드 스칼라 값이 하나 이상의 바이트로 구성될지도 모른다는 것을 확실히 기억해주세요.

스트링으로부터 문자소 클러스터를 얻는 방법은 복잡해서, 이 기능은 표준 라이브러리를 통해 제공되지 않습니다. 여러분이 원하는 기능이 이것이라면 crates.io에서 사용 가능한 크레이트가 있습니다.

스트링은 그렇게 단순하지 않습니다

  • 종합하면, 스트링은 복잡합니다.
  • 다른 프로그래밍 언어들은 이러한 복잡성을 프로그래머에게 어떻게 보여줄지에 대해 각기 다른 선택을 합니다.
  • 러스트는 String 데이터의 올바른 처리가 모든 러스트 프로그램에 대한 기본적인 동작이 되도록 선택했는데, 이는 솔직히 프로그래머들이 UTF-8 데이터를 처리하는데 있어 더 많은 생각을 해야한다는 의미입니다.
  • 이러한 거래는 다른 프로그래밍 언어들에 비해 더 복잡한 스트링을 노출시키지만, 한편으로는 여러분의 개발 생활 주기 후반에 비 ASCII 캐릭터를 포함하는 에러를 처리해야 하는 것을 막아줄 것입니다.

이것보다 살짝 덜 복잡한 것으로 옮겨 갑시다: 해쉬맵이요!

해쉬맵(hash map)

마지막으로 볼 일반적인 컬렉션은 해쉬맵입니다.

  • HashMap<K, V> 타입은 K 타입의 키에 V 타입의 값을 매핑한 것을 저장합니다.
  • 이 매핑은 해쉬 함 수(hashing function) 을 통해 동작하는데, 해쉬 함수는 이 키와 값을 메모리 어디에 저장할지 결정합니다.
  • 많은 다른 프로그래밍 언어들도 이러한 종류의 데이터 구조를 지원하지만, 종종 해쉬, , 오브젝트, 해쉬 테이블, 혹은 연관 배열(associative) 등과 같은 그저 몇몇 다른 이름으로 이용됩니다.

이 장에서는 해쉬맵의 기본 API를 다룰 것이지만, 표준 라이브러리의 HashMap에 정의되어 있는 함수 중에는 더 좋은 것들이 숨어있습니다.

항상 말했듯이, 더 많은 정보를 원하신다면 표준 라이브러리 문서를 확인하세요.

새로운 해쉬맵 생성하기

우리는 빈 해쉬맵을 new로 생성할 수 있고, insert를 이용하여 요소를 추가할 수 있습니다.

	use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

Listing 8-20: 새로운 해쉬맵을 생성하여 몇 개의 키와 값을 집어넣기

  • 먼저 표준 라이브러리의 컬렉션 부분으로부터 HashMap을 use로 가져와야할 필요가 있음을 주목하세요.
  • 우리가 보고 있는 세 가지 일반적인 컬렉션 중에서 이 해쉬맵이 제일 덜 자주 사용되는 것이기 때문에, 프렐루드(prelude) 내에 자동으로 가져와지는 기능에 포함되어 있지 않습니다.
  • 또한 해쉬맵은 표준 라이브러리로부터 덜 지원을 받습니다;
  • 예를 들면 해쉬맵을 생성하는 빌트인 매크로가 없습니다.
  • 벡터와 마찬가지로, 해쉬맵도 데이터를 힙에 저장합니다.
  • 이 HashMap은 String 타입의 키와 i32 타입의 값을 갖습니다.
  • 벡터와 비슷하게 해쉬맵도 동질적입니다:
  • 모든 키는 같은 타입이어야 하고, 모든 값도 같은 타입이여야 합니다. -> ???
  • 해쉬맵을 생성하는 또다른 방법은 튜플의 벡터에 대해 collect 메소드를 사용하는 것인데, 이 벡터의 각 튜플은 키와 키에 대한 값으로 구성되어 있습니다.
  • collect 메소드는 데이터를 모아서 HashMap을 포함한 여러 컬렉션 타입으로 만들어줍니다.
    • 예를 들면, 만약 두 개의 분리된 벡터에 각각 팀 이름과 초기 점수를 갖고 있다면, 우리는 zip 메소드를 이용하여 “Blue”와 10이 한 쌍이 되는 식으로 튜플의 벡터를 생성할 수 있습니다.
    • 그 다음 Listing 8-21과 같이 collect 메소드를 사용하여 튜플의 벡터를 HashMap으로 바꿀 수 있습니다:
use std::collections::HashMap;

let teams = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];

let scores: HashMap<_, _> = teams.iter().zip(initial_scores.iter()).collect();

Listing 8-21: 팀의 리스트와 점수의 리스트로부터 해쉬맵 생성하기

타입 명시 HashMap<_, _>가 필요한데 이는 collect가 다른 많은 데이터 구조로 바뀔 수 있고, 러스트는 여러분이 특정하지 않으면 어떤 것을 원하는지 모르기 때문입니다.

그러나 키와 값의 타입에 대한 타입 파라미터에 대해서는 밑줄을 쓸 수 있으며 러스트는 벡터에 담긴 데이터의 타입에 기초하여 해쉬에 담길 타입을 추론할 수 있습니다.

해쉬맵과 소유권

  • i32와 같이 Copy 트레잇을 구현한 타입에 대하여, 그 값들은 해쉬맵 안으로 복사됩니다.
  • String과 같이 소유된 값들에 대해서는, 아래의 Listing 8-22와 같이 값들이 이동되어 해쉬맵이 그 값들에 대한 소유자가 될 것입니다:
use std::collections::HashMap;

let field_name = String::from("Favorite color");
let field_value = String::from("Blue");

let mut map = HashMap::new();
map.insert(field_name, field_value);
// field_name과 field_value은 이 지점부터 유효하지 않습니다.
// 이들을 이용하는 시도를 해보고 어떤 컴파일러 에러가 나오는지 보세요!

Listing 8-22: 키와 값이 삽입되는 순간 이들이 해쉬맵의 소유가 되는 것을 보여주는 예

insert를 호출하여 field_name과 field_value를 해쉬맵으로 이동시킨 후에는 더 이상 이 둘을 사용할 수 없습니다.

  • 만일 우리가 해쉬맵에 값들의 참조자들을 삽입한다면, 이 값들은 해쉬맵으로 이동되지 않을 것입니다.
  • 하지만 참조자가 가리키고 있는 값은 해쉬맵이 유효할 때까지 계속 유효해야합니다.
  • 이것과 관련하여 10장의 “라이프타임을 이용한 참조자 유효화”절에서 더 자세히 이야기할 것입니다.

해쉬맵 내의 값 접근하기

Listing 8-23과 같이 해쉬맵의 get 메소드에 키를 제공하여 해쉬맵으로부터 값을 얻어올 수 있습니다:

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

let team_name = String::from("Blue");
let score = scores.get(&team_name);
  • 여기서 score는 블루 팀과 연관된 값을 가지고 있을 것이고, 결과값은 Some(&10)일 것입니다.
  • 결과값은 Some으로 감싸져 있는데 왜냐하면 get이 Option<&V>를 반환하기 때문입니다;
  • 만일 해쉬맵 내에 해당 키에 대한 값이 없다면 get은 None을 반환합니다.
  • 우리가 6장에서 다루었던 방법 중 하나로 Option을 처리해야 할 것입니다.

우리는 벡터에서 했던 방법과 유사한 식으로 for 루프를 이용하여 해쉬맵에서도 각각의 키/값 쌍에 대한 반복작업을 할 수 있습니다:

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

for (key, value) in &scores {
println!("{}: {}", key, value);
}
// Yellow: 50
// Blue: 10

해쉬맵 갱신하기

키와 값의 개수가 증가할 수 있을지라도, 각각의 개별적인 키는 한번에 연관된 값 하나만을 가질 수 있습니다.

해쉬맵 내의 데이터를 변경하길 원한다면, 키에 이미 값이 할당되어 있을 경우에 대한 처리를 어떻게 할지 결정해야 합니다.

  • 예전 값을 완전히 무시하면서 예전 값을 새 값으로 대신할 수도 있습니다.
  • 혹은 예전 값을 계속 유지하면서 새 값은 무시하고, 해당 키에 값이 할당되지 않을 경우에만 새 값을 추가하는 방법을 선택할 수도 있습니다.
  • 또는 예전 값과 새 값을 조합할 수도 있습니다.

값을 덮어쓰기

만일 해쉬맵에 키와 값을 삽입하고, 그 후 똑같은 키에 다른 값을 삽입하면, 키에 연관지어진 값은 새 값으로 대신될 것입니다. 아래 Listing 8-24의 코드가 insert를 두 번 호출함에도, 해쉬맵은 딱 하나의 키/값 쌍을 담게 될 것인데 그 이유는 두 번 모두 블루 팀의 키에 대한 값을 삽입하고 있기 때문입니다:

키에 할당된 값이 없을 경우에만 삽입하기

  • 특정 키가 값을 가지고 있는지 검사하고, 만일 가지고 있지 않다면 이 키에 대한 값을 삽입하고자 하는 경우는 흔히 발생합니다.
  • 해쉬맵은 이를 위하여 entry라고 하는 특별한 API를 가지고 있는데, 이는 우리가 검사하고자 하는 키를 파라미터로 받습니다.
  • entry 함수의 리턴값은 열거형 Entry인데, 해당 키가 있는지 혹은 없는지를 나타냅니다.
  • 우리가 옐로우 팀에 대한 키가 연관된 값을 가지고 있는지 검사하고 싶어한다고 해봅시다. 만일 없다면, 값 50을 삽입하고, 블루팀에 대해서도 똑같이 하고 싶습니다. 엔트리 API를 사용한 코드는 아래의 Listing 8-25와 같습니다:
use std::collections::HashMap;

let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);

scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(50);

println!("{:?}", scores);

Listing 8-25: entry 메소드를 이용하여 어떤 키가 값을 이미 갖고 있지 않을 경우에만 추가하기

Entry에 대한 or_insert 메소드는 해당 키가 존재할 경우 관련된 Entry 키에 대한 값을 반환하도록 정의되어 있고, 그렇지 않을 경우에는 파라미터로 주어진 값을 해당 키에 대한 새 값을 삽입하고 수정된 Entry에 대한 값을 반환합니다. 이 방법은 우리가 직접 로직을 작성하는 것보다 훨씬 깔끔하고, 게다가 빌림 검사기와 잘 어울려 동작합니다.

예전 값을 기초로 값을 갱신하기

해쉬맵에 대한 또다른 흔한 사용 방식은 키에 대한 값을 찾아서 예전 값에 기초하여 값을 갱신하는 것입니다. 예를 들어, Listing 8-26은 어떤 텍스트 내에 각 단어가 몇번이나 나왔는지를 세는 코드를 보여줍니다. 단어를 키로 사용하는 해쉬맵을 이용하여 해당 단어가 몇번이나 나왔는지를 유지하기 위해 값을 증가시켜 줍니다. 만일 어떤 단어를 처음 본 것이라면, 값 0을 삽입할 것입니다.

use std::collections::HashMap;

let text = "hello world wonderful world";
let mut map = HashMap::new();
for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}
println!("{:?}", map);

Listing 8-26: 단어와 횟수를 저장하는 해쉬맵을 사용하여 단어의 등장 횟수 세기

  • 이 코드는 {"world": 2, "hello": 1, "wonderful": 1}를 출력할 것입니다.
  • or_insert 메소드는 실제로는 해당 키에 대한 값의 가변 참조자 (&mut V)를 반환합니다.
  • 여기서는 count 변수에 가변 참조자를 저장하였고, 여기에 값을 할당하기 위해 먼저 애스터리스크 (*)를 사용하여 count를 역참조해야 합니다.
  • 가변 참조자는 for 루프의 끝에서 스코프 밖으로 벗어나고, 따라서 모든 값들의 변경은 안전하며 빌림 규칙에 위배되지 않습니다.

해쉬 함수

  • 기본적으로, HashMap은 서비스 거부 공격(Denial of Service(DoS) attack)에 저항 기능을 제공할 수 있는 암호학적으로 보안되는 해쉬 함수를 사용합니다.
  • 이는 사용 가능한 가장 빠른 해쉬 알고리즘은 아니지만, 성능을 떨어트리면서 더 나은 보안을 취하는 거래는 가치가 있습니다.
  • 만일 여러분이 여러분의 코드를 프로파일하여 기본 해쉬 함수가 여러분의 목표에 관해서는 너무 느리다면, 다른 해쉬어(hasher) 를 특정하여 다른 함수로 바꿀 수 있습니다.
  • 해쉬어는 BuildHasher 트레잇을 구현한 타입을 말합니다.
    • 트레잇과 이를 어떻게 구현하는지에 대해서는 10장에서 다룰 것입니다.
    • 여러분의 해쉬어를 바닥부터 새로 구현해야할 필요는 없습니다;
  • crates.io에서는 많은 수의 범용적인 해쉬 알고리즘을 구현한 해쉬어를 제공하는 공유 라이브러리를 제공합니다.

정리

벡터, 스트링, 그리고 해쉬맵은 프로그램 내에서 여러분이 데이터를 저장하고, 접근하고, 수정하고 싶어하는 곳마다 필요한 수많은 기능들을 제공해줄 것입니다.

이제 여러분이 풀 준비가 되어있어야 할만한 몇가지 연습문제를 소개합니다:

  • 정수 리스트가 주어졌을 때, 벡터를 이용하여 이 리스트의 평균값(mean, average), 중간값(median, 정렬했을 때 가장 가운데 위치한 값), 그리고 최빈값(mode, 가장 많이 발생한 값; 해쉬맵이 여기서 도움이 될 것입니다)를 반환해보세요.
  • 스트링을 피그 라틴(pig Latin)으로 변경해보세요. 각 단어의 첫번째 자음은 단어의 끝으로 이동하고 “ay”를 붙이므로, “first”는 “irst-fay”가 됩니다. 모음으로 시작하는 단어는 대신 끝에 “hay”를 붙입니다. (“apple”은 “apple-hay”가 됩니다.) UTF-8 인코딩에 대해 기억하세요!
  • 해쉬맵과 벡터를 이용하여, 사용자가 회사 내의 부서에 대한 피고용인 이름을 추가할 수 있도록 하는 텍스트 인터페이스를 만들어보세요. 예를들어 “Add Sally to Engineering”이나 “Add Amir to Sales” 같은 식으로요. 그후 사용자가 각 부서의 모든 사람들에 대한 리스트나 알파벳 순으로 정렬된 부서별 모든 사람에 대한 리스트를 조회할 수 있도록 해보세요.

표준 라이브러리 API 문서는 이 연습문제들에 대해 도움이 될만한 벡터, 스트링, 그리고 해쉬맵의 메소드들을 설명해줍니다!

우리는 연산이 실패할 수 있는 더 복잡한 프로그램으로 진입하고 있는 상황입니다; 따라서, 다음은 에러 처리에 대해 다룰 완벽한 시간이란 뜻이죠!

Chap.7 crates and modules

· 43 min read
brown
FE developer

거대한 프로그램을 작성할 때에는 코드 관리가 무척 중요합니다.

  • 어느 시점부터는 머릿속에서 생각하는 것만으로는 전체 프로그램의 변화를 쫓아갈 수 없기 때문입니다.
  • 코드에서 연관된 기능은 묶고 서로 다른 기능은 분리해두어야,
  • 이후 특정 기능을 구현하는 코드를 찾거나 변경할 때 헤맬 필요 없습니다.
  • 따라서 프로젝트 규모가 커지면 코드를 여러 모듈, 여러 파일로 나누어 관리할 필요가 있습니다.

한 패키지 내에는 여러 바이너리 크레이트와 (원할 경우) 라이브러리 크레이트를 포함시킬 수 있으므로, 커진 프로젝트의 각 부분을 크레이트로 나눠서 외부 라이브러리처럼 쓸 수 있습니다.

이번 장에서 배워 볼 것은 이러한 기술들입니다. 상호연관된 패키지들로 이루어진 대규모 프로젝트의 경우, 14장 “Cargo Workspaces” 절에서 다룰 예정인, Cargo에서 제공하는 Workspace 기능을 이용합니다.

그룹화 외에도, 세부 구현을 캡슐화하면 더 고수준으로 코드를 재사용할 수 있습니다. 어떤 작업을 구현해두고 다른 코드에서 해당 코드의 공개 인터페이스를 통해 호출하면, 세부적인 작동은 알 필요 없죠. 여러분은 어떤 부분을 다른 코드에서 사용할 수 있도록 공개하고, 어떤 부분을 비공개된 세부 구현으로 만들어 자유롭게 수정할 수 있도록 할지를 정의하는 방식으로 코드를 작성합니다. 머릿속에 담아두어야 하는 정보의 양을 줄이는 또 다른 방법이기도 합니다.

스코프 개념도 관련되어 있습니다. 중첩된 컨텍스트에 작성한 코드는 "스코프 내" 정의된 다양한 이름들이 사용됩니다. 프로그래머나 컴파일러가 코드를 읽고, 쓰고, 컴파일할 때는 어떤 위치의 어떤 이름이 무엇을 의미하는지 알아야 합니다. 해당 이름이 변수인지, 함수인지, 열거형인지, 모듈인지, 상수인지, 그 외 요소인지 말이죠. 동일한 스코프 내에는 같은 이름을 가진 요소가 둘 이상 존재할 수 없기 때문에, 스코프를 의도적으로 생성해 어떤 이름은 스코프 내에 위치하고 어떤 이름은 스코프 밖에 위치하도록 조정하기도 합니다. (이름 충돌을 해결하는 도구도 존재합니다)

러스트에는 코드 조직화에 필요한 기능이 여럿 있습니다. 어떤 세부 정보를 외부에 노출할지, 비공개로 둘지, 프로그램의 스코프 내 항목 이름 등 다양합니다. 이를 통틀어 모듈 시스템 이라 하며, 다음 기능들이 포함됩니다.

  • 패키지 크레이트를 빌드하고, 테스트하고, 공유하는데 사용하는 Cargo 기능입니다.
  • 크레이트 라이브러리나 실행 가능한 모듈로 구성된 트리 구조입니다.
  • 모듈use: 구조, 스코프를 제어하고, 조직 세부 경로를 감추는 데 사용합니다.
  • 경로 구조체, 함수, 모듈 등의 이름을 지정합니다.

이번 장에서는 이 기능들을 모두 다뤄보면서 어떻게 작동하고, 어떻게 사용해서 스코프를 관리하는지 등을 배워보겠습니다. 이번 장을 마치고 나면, 모듈 시스템을 확실히 이해하고 스코프를 자유자재로 다룰 수 있을 거랍니다!

7.1 패키지, 크레이트

모듈 시스템에서 처음 다뤄볼 내용은 패키지크레이트입니다.

  • 크레이트는 바이너리일 수도 있고, 라이브러리일 수도 있습니다.
  • 러스트 컴파일러는 크레이트 루트 라는 소스 파일부터 컴파일을 시작해서 여러분이 작성한 크레이트의 루트 모듈을 구성합니다. (모듈은 "모듈을 정의하여 스코프 및 공개 여부 제어하기"에서 알아볼 예정입니다) 패키지 는 하나 이상의 크레이트로 기능을 구성해 제공합니다.
  • 패키지 내 Cargo.toml 파일은 패키지의 크레이트를 빌드하는 법을 나타냅니다.

패키지에 무엇을 포함할 수 있는가에 대해서는 규칙이 몇 가지 있습니다.

  • 라이브러리 크레이트는 하나만 넣을 수 있습니다.
  • 바이너리 크레이트는 원하는 만큼 포함할 수 있습니다.
  • 단, 패키지에는 적어도 하나 이상의 크레이트(라이브러리이건, 바이너리이건)가 포함되어야 합니다.

패키지를 생성할 때 어떤 일이 일어나는지 살펴보죠. 먼저 cargo new 명령어를 입력합니다.

$ cargo new my-project
Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs
  • 명령어를 입력하면 Cargo는 Cargo.toml 파일을 생성하여, 새로운 패키지를 만들어 줍니다.

    • 패키지명과 같은 이름의 바이너리 크레이트는 크레이트 루트가 src/main.rs 라는 규칙이 있기 때문에, Cargo.toml 파일을 살펴보아도 src/main.rs 가 따로 언급되진 않습니다.
  • 마찬가지로, 패키지 디렉토리에 src/lib.rs 파일이 존재할 경우, Cargo는 해당 패키지가 패키지명과 같은 이름의 라이브러리 크레이트를 포함하고 있다고 판단합니다.

    • 물론 그 라이브러리 크레이트의 크레이트 루트는 src/lib.rs 이고요. Cargo는 크레이트를 빌드할 때(라이브러리이건, 바이너리이건) 크레이트 루트 파일을 rustc 로 전달합니다.
  • 현재 패키지는 src/main.rs 만 포함하고 있으므로 my-project 바이너리 크레이트만 포함합니다.

  • 만약 어떤 패키지가 src/main.rs 와 src/lib.rs 를 포함한다면 해당 패키지는 패키지와 이름이 같은 바이너리, 라이브러리 크레이트를 포함하게 됩니다.

  • src/bin 디렉토리 내에 파일을 배치하면 각각의 파일이 바이너리 크레이트가 되어, 여러 바이너리 크레이트를 패키지에 포함할 수 있습니다.

  • 크레이트는 관련된 기능을 그룹화함으로써 특정 기능을 쉽게 여러 프로젝트 사이에서 공유합니다.

  • 예를 들어, 2장 에서 사용한 rand 크레이트는 랜덤한 숫자를 생성하는 기능을 제공합니다.

  • 우린 프로젝트 스코프에 rand 크레이트를 가져오기만 하면 우리가 만든 프로젝트에서 랜덤 숫자 생성 기능을 이용할 수 있죠.

  • rand 크레이트가 제공하는 모든 기능은 크레이트의 이름인 rand 를 통해 접근 가능합니다.

크레이트의 기능이 각각의 스코프를 갖도록 하면 특정 기능이 우리 크레이트에 있는지, rand 크레이트에 있는지를 명확하게 알 수 있으며, 잠재적인 충돌을 방지할 수도 있습니다. 예를 들어, 우리가 만든 크레이트에 Rng 라는 이름의 구조체를 정의한 상태로, Rng 트레잇을 제공하는 rand 크레이트를 의존성에 추가하더라도 컴파일러는 Rng 라는 이름이 무엇을 가리키는지 정확히 알 수 있습니다. Rng 는 우리가 만든 크레이트 내에서 정의한 struct Rng 를 가르키고, rand 크레이트의 Rng 트레잇은 rand::Rng 로 접근해야 하죠.

7.2 모듈을 정의하여 스코프 및 공개 여부 제어하기

이번에는 모듈, 항목의 이름을 지정하는 경로(path), 스코프에 경로를 가져오는 use 키워드, 항목을 공개하는 데 사용하는 pub 키워드를 알아보겠습니다. as 키워드, 외부 패키지, 글롭 연산자 등도 다룰 예정이지만, 일단은 모듈에 집중하죠!

  • 모듈(module) 은 가독성과 재사용성을 위해서 크레이트 내 코드를 그룹화하는 데 사용됩니다.
  • 외부에 공개(public) 할지, 내부의 세부 구현이니 외부에서 사용할 수 없도록 비공개(private) 할지 제어하는 역할도 있습니다.

예시로, 레스토랑 기능을 제공하는 라이브러리 크레이트를 작성한다고 가정해보죠. 코드 구조에 집중할 수 있도록 레스토랑을 실제 코드로 구현하지는 않고, 본문은 비워둔 함수 시그니처만 정의하겠습니다.

레스토랑 업계에서는 레스토랑을 크게 접객 부서(front of house) 와 지원 부서(back of house) 로 나눕니다. 접객 부서는 호스트가 고객을 안내하고, 웨이터가 주문 접수 및 결제를 담당하고, 바텐더가 음료를 만들어 주는 곳입니다. 지원 부서는 셰프, 요리사, 주방보조가 일하는 주방과 매니저가 행정 업무를 하는 곳입니다.

함수를 중첩 모듈로 구성하면 크레이트 구조를 실제 레스토랑이 일하는 방식과 동일하게 구성할 수 있습니다. cargo new --lib restaurant 명령어를 실행하여 restaurant 라는 새 라이브러리를 생성하고, Listing 7-1 코드를 src/lib.rs 에 작성하여 모듈, 함수 시그니처를 정의합시다.

mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
fn seat_at_table() {}
}
mod serving {
fn take_order() {}
fn serve_order() {}
fn take_payment() {}
}
}

mod 키워드와 모듈 이름(이 경우 front_of_house)을 명시하고, 본문을 중괄호로 감싸 모듈을 정의하였습니다. hostingserving 모듈처럼, 모듈 내에는 다른 모듈을 넣을 수 있습니다. 모듈은 구조체, 열거형, 상수, 트레잇, 함수(Listing 7-1처럼) 등의 항목 정의를 지닐 수 있습니다.

crate
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
├── serve_order
└── take_payment

위 코드를 모듈 트리로 나타낸 모습

  • 트리는 모듈이 서로 어떻게 중첩되어 있는지 보여줍니다
    • (예시: hosting 모듈은 front_of_house 내에 위치함)
  • hostingserving 모듈이 둘 다 동일하게 front_of_house 모듈 내에 위치한 것처럼, 어떤 모듈이 형제 관계에 있는지 나타내기도 합니다.
  • 가족 관계로 계속 비유하면, 모듈 A가 모듈 B 내에 있을 경우, 모듈 A는 모듈 B의 자식 이며, 모듈 B는 모듈 A의 부모 라고 표현할 수 있습니다.
  • 전체 모듈 트리 최상위에 crate 라는 모듈이 암묵적으로 위치한다는 점을 기억해두세요.

모듈 트리에서 컴퓨터 파일 시스템의 디렉토리 트리를 연상하셨다면, 적절한 비유입니다! 파일 시스템의 디렉토리처럼, 여러분은 모듈로 코드를 조직화합니다.

7.3 경로를 사용해 모듈 트리에서 항목 가리키기

러스트 모듈 조직도에서 항목을 찾는 방법은, 파일 시스템에서 경로를 사용하는 방법과 동일합니다.

경로는 두 가지 형태가 존재합니다.

  • 절대 경로 는 크레이트 이름이나 crate 리터럴을 사용하며, 크레이트 루트를 기준점으로 사용합니다.
  • 상대 경로 는 selfsuper 를 사용하며, 현재 모듈을 기준점으로 사용합니다.

절대 경로, 상대 경로 뒤에는 ::으로 구분된 식별자가 하나 이상 따라옵니다.

  • add_to_waitlist 함수를 호출하려면 어떻게 해야 할까요?
  • 다시 말해서, add_to_waitlist 함수의 경로는 무엇일까요?
  • eat_at_restaurant 라는 새로운 함수에서 add_to_waitlist 함수를 두 가지 방법으로 호출하는 예시를 보여줍니다.
  • eat_at_restaurant 함수는 우리가 만든 라이브러리 크레이트의 공개 API 중 하나입니다.
  • 따라서 pub 키워드로 지정되어 있습니다.
  • pub 키워드는 "pub 키워드로 경로 노출하기" 절에서 자세히 알아볼 예정입니다.
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
// Absolute path
crate::front_of_house::hosting::add_to_waitlist();
// Relative path
front_of_house::hosting::add_to_waitlist();
}
  • eat_at_restaurant 함수에서 처음 add_to_waitlist 함수를 호출할 때는 절대 경로를 사용했습니다.

    • add_to_waitlist 함수는 eat_at_restaurant 함수와 동일한 크레이트에 정의되어 있으므로, 절대 경로의 시작점에 crate 키워드를 사용할 수 있습니다.
    • crate 뒤에는 add_to_waitlist 함수에 도달할 때까지의 모듈을 연속해서 작성합니다.
    • 파일 시스템 구조로 생각해 보죠. /front_of_house/hosting/add_to_waitlist 경로를 작성하여 add_to_waitlist 프로그램을 실행했군요.
    • crate를 작성해 크레이트 루트를 기준으로 사용하는 것은 셸(shell)에서 / 로 파일 시스템의 최상위 디렉토리를 기준으로 사용하는 것과 같습니다.
  • eat_at_restaurant 함수에서 두 번째로 add_to_waitlist 함수를 호출할 때는 상대 경로를 사용했습니다.

    • 경로는 모듈 트리에서 eat_at_restaurant 함수와 동일한 위치에 정의되어 있는 front_of_house 모듈로 시작합니다.
    • 파일 시스템으로 비유하자면 front_of_house/hosting/add_to_waitlist 가 되겠네요.
    • 파일 시스템 경로에서, 항목의 이름으로 시작하는 경로는 상대 경로입니다.
  • 상대 경로, 절대 경로 중 무엇을 사용할지는 프로젝트에 맞추어 여러분이 선택해야 합니다.

  • 이는 여러분이 항목을 정의하는 코드와 항목을 사용하는 코드를 분리하고 싶은지, 혹은 같이 두고 싶은지에 따라 결정되어야 합니다.

  • 예를 들어, front_of_house 모듈과 eat_at_restaurant 함수를 customer_experience 라는 모듈 내부로 이동시켰다고 가정해보죠.

  • add_to_waitlist 함수를 절대 경로로 작성했다면 코드를 수정해야 하지만, 상대 경로는 수정할 필요가 없습니다. 반면, eat_at_restaurant 함수를 분리하여 dining 이라는 모듈 내부로 이동시켰다면, add_to_waitlist 함수를 가리키는 절대 경로는 수정할 필요가 없지만, 상대 경로는 수정해야 합니다.

  • 우리가 선호하는 경로는 절대 경로입니다.

    • 항목을 정의하는 코드와 호출하는 코드는 분리되어 있을 가능성이 높기 때문입니다.

우리는 hosting 모듈과 add_to_waitlist 함수의 경로를 정확히 명시했지만, 해당 영역은 비공개 영역이기 때문에 러스트가 접근을 허용하지 않습니다.

  • 모듈은 코드를 조직화하는 용도로만 쓰이지 않습니다.

  • 러스트의 비공개 경계(privacy boundary) 를 정의하는 역할도 있습니다.

  • 캡슐화된 세부 구현은 외부 코드에서 호출하거나 의존할 수 없고, 알 수도 없습니다.

  • 따라서 비공개로 만들고자 하는 함수나 구조체가 있다면, 모듈 내에 위치시키면 됩니다.

  • 러스트에서, 모든 항목(함수, 메소드, 구조체, 열거형, 모듈, 상수)은 기본적으로 비공개입니다.

  • 부모 모듈 내 항목은 자식 모듈 내 비공개 항목을 사용할 수 없지만, 자식 모듈 내 항목은 부모 모듈 내 항목을 사용할 수 있습니다.

  • 이유는, 자식 모듈의 세부 구현은 감싸져서 숨겨져 있지만, 자식 모듈 내에서는 자신이 정의된 컨텍스트를 볼 수 있기 때문입니다.

  • 러스트 모듈 시스템은 내부의 세부 구현을 기본적으로 숨기도록 되어 있습니다.

    • 이로써, 여러분은 외부 코드의 동작을 망가뜨릴 걱정 없이 수정할 수 있는 코드가 어느 부분인지 알 수 있죠.
    • 만약, 자식 모듈의 내부 요소를 공개(public)함으로써 외부 상위 모듈로 노출하고자 한다면, pub 키워드를 사용합니다.

pub 키워드로 경로 노출하기

  • hosting 모듈이 비공개임을 의미하던 Listing 7-4 오류로 돌아와보죠.

  • 부모 모듈 내 eat_at_restaurant 함수가 자식 모듈 내 add_to_waitlist 함수에 접근해야 하니, hosting 모듈에 pub 키워드를 작성했습니다.

    • 우린 mod hosting 앞에 pub 키워드를 추가하여 모듈을 공개했습니다.
    • 따라서, front_of_house 에 접근할 수 있다면 hosting 모듈에도 접근할 수 있죠.
    • 하지만, hosting 모듈의 내용은 여전히 비공개입니다. 모듈을 공개했다고 해서 내용까지 공개되지는 않습니다. 모듈의 pub 키워드는 상위 모듈이 해당 모듈을 가리킬 수 있도록 할 뿐입니다.
  • add_to_waitlist 함수가 비공개라는 내용을 담고 있습니다.

  • 비공개 규칙은 구조체, 열거형, 함수, 메소드, 모듈 모두 적용됩니다.

  • 절대 경로는 크레이트 모듈 트리의 최상위인 crate로 시작합니다.

  • 그리고 크레이트 루트 내에 정의된 front_of_house 모듈이 이어집니다.

  • front_of_house 모듈은 공개가 아니지만, eat_at_restaurant 함수와 front_of_house 모듈은 같은 모듈 내에 정의되어 있으므로 (즉, 서로 형제 관계이므로) eat_at_restaurant 함수에서 front_of_house 모듈을 참조할 수 있습니다.

  • 다음은 pub 키워드가 지정된 hosting 모듈입니다.

    • hosting의 부모 모듈에 접근할 수 있으니, hosting 에도 접근할 수 있습니다.
  • 마지막 add_to_waitlist 함수 또한 pub 키워드가 지정되어 있고, 부모 모듈에 접근할 수 있으니, 호출 가능합니다!

  • 상대 경로는 첫 번째 과정을 제외하면 절대 경로와 동일합니다.

  • 상대 경로는 크레이트 루트에서 시작하지 않고, front_of_house 로 시작합니다.

  • front_of_house 모듈은 eat_at_restaurant 함수와 동일한 모듈 내에 정의되어 있으므로, eat_at_restaurant 함수가 정의되어 있는 모듈에서 시작하는 상대 경로를 사용할 수 있습니다.

  • 이후 hostingadd_to_waitlist 은 pub으로 지정되어 있으므로 나머지 경로도 문제 없습니다.

  • 따라서 이 함수 호출도 유효합니다!

super로 시작하는 상대 경로

super로 시작하는 상대 경로는 부모 모듈을 기준점으로 사용합니다. 이는 파일시스템 경로에서 .. 로 시작하는 것과 동일합니다.

부모 모듈을 기준으로 삼아야 하는 상황은 언제일까요?

back_of_house 모듈과 serve_order 함수는 크레이트 모듈 구조 변경 시 서로의 관계를 유지한 채 함께 이동될 가능성이 높습니다. 그러므로 super를 사용하면, 추후에 다른 모듈에 이동시키더라도 수정해야 할 코드를 줄일 수 있습니다

구조체, 열거형을 공개하기

  • pub 키워드로 구조체와 열거형을 공개할 수도 있습니다.
  • 단, 알아두어야 할 점이 몇 가지 있습니다.
  • 구조체 정의에 pub를 사용하면 구조체는 공개되지만, 구조체 내 필드는 비공개로 유지됩니다.
  • 공개 여부는 각 필드마다 정할 수 있습니다.
  • Listing 7-9는 공개 구조체 back_of_house::Breakfast를 정의하고 toast 필드는 공개하지만 seasonal_fruit 필드는 비공개로 둔 예제입니다.
    • 이는 레스토랑에서 고객이 식사와 같이 나올 빵 종류를 선택하고, 셰프가 계절과 재고 상황에 맞춰서 식사에 포함할 과일을 정하는 상황을 묘사한 예제입니다.
    • 과일은 빈번히 변경되므로, 고객은 직접 과일을 선택할 수 없으며 어떤 과일을 받을지도 미리 알 수 없습니다.
#![allow(unused)]
fn main() {
mod back_of_house {
pub struct Breakfast {
pub toast: String,
seasonal_fruit: String,
}
impl Breakfast {
pub fn summer(toast: &str) -> Breakfast {
Breakfast {
toast: String::from(toast),
seasonal_fruit: String::from("peaches"),
}
}
}
}
pub fn eat_at_restaurant() {
// Order a breakfast in the summer with Rye toast
let mut meal = back_of_house::Breakfast::summer("Rye");
// Change our mind about what bread we'd like
meal.toast = String::from("Wheat");
println!("I'd like {} toast please", meal.toast);
// The next line won't compile if we uncomment it; we're not allowed
// to see or modify the seasonal fruit that comes with the meal
// meal.seasonal_fruit = String::from("blueberries");
}
}
  • back_of_house::Breakfast 구조체 내 toast 필드는 공개 필드이기 때문에 eat_at_restaurant 함수에서 점 표기법으로 toast 필드를 읽고 쓸 수 있습니다.
  • 반면, seasonal_fruit 필드는 비공개 필드이기 때문에 eat_at_restaurant 함수에서 사용할 수 없습니다.
  • 한번 seasonal_fruit 필드를 수정하는 코드의 주석을 해제하고 어떤 오류가 발생하는지 확인해보세요!
  • back_of_house::Breakfast 구조체는 비공개 필드를 갖고 있기 때문에, Breakfast 인스턴스를 생성할 공개 연관 함수를 반드시 제공해야 합니다.
    • 예제에서는 summer 함수입니다
    • 만약 Breakfast 구조체에 그런 함수가 존재하지 않을 경우, eat_at_restaurant 함수에서 Breakfast 인스턴스를 생성할 수 없습니다.
    • eat_at_restaurant 함수 내에서는 비공개 필드인 seasonal_fruit 필드의 값을 지정할 방법이 없기 때문입니다.
  • 반대로, 열거형공개로 지정할 경우 모든 variant가 공개됩니다.
  • 열거형을 공개하는 방법은 enum 키워드 앞에 pub 키워드만 작성하면 됩니다.
mod back_of_house {
pub enum Appetizer {
Soup,
Salad,
}
}
pub fn eat_at_restaurant() {
let order1 = back_of_house::Appetizer::Soup;
let order2 = back_of_house::Appetizer::Salad;
}
  • Appetizer 열거형을 공개하였으니, eat_at_restaurant 함수에서 SoupSalad variant를 사용할 수 있습니다.
  • 공개 열거형의 variant가 전부 공개되는 이유는
    • variant가 전부 공개되지 않은 열거형의 활용도가 낮고,
    • 모든 variant에 pub 키워드를 작성하는 것도 귀찮은 일이기 때문입니다.

남은 pub 키워드 관련 내용은 모듈 시스템의 마지막 기능인 use 키워드입니다. 먼저 use 키워드 단독 사용법을 다루고, 그다음 use 와 pub 을 연계하여 사용하는 방법을 다루겠습니다.

use 키워드로 경로를 스코프 내로 가져오기

  • 앞서 작성한 함수 호출 경로는 너무 길고 반복적으로 느껴지기도 합니다.
  • 예를 들어, Listing 7-7에서는 절대 경로를 사용하건 상대 경로를 사용하건, add_to_waitlist 호출할 때마다 front_of_househosting 모듈을 매번 명시해 주어야 하죠.
  • use 키워드를 사용해 경로를 스코프 내로 가져오면 이 과정을 단축하여 마치 로컬 항목처럼 호출할 수 있습니다.
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
hosting::add_to_waitlist();
hosting::add_to_waitlist();
}
  • 스코프에 use 키워드와 경로를 작성하는 건 파일 시스템에서 심볼릭 링크를 생성하는 것과 유사합니다.

  • 크레이트 루트에 use crate::front_of_house::hosting를 작성하면 해당 스코프에서 hosting 모듈을 크레이트 루트에 정의한 것처럼 사용할 수 있습니다.

  • use 키워드로 가져온 경우 또한 다른 경로와 마찬가지로 비공개 규칙이 적용됩니다.

  • use 키워드에 상대 경로를 사용할 수도 있습니다.

    • use self::front_of_house::hosting;

보편적인 use 경로 작성법

add_to_waitlist 함수까지 경로를 전부 작성하지 않고, use crate::front_of_house::hosting 까지만 작성한 뒤 hosting::add_to_waitlist 코드로 함수를 호출하는 점이 의아하실 수도 있습니다.

use 키워드로 add_to_waitlist 함수를 직접 가져오기 (보편적이지 않은 작성 방식)

mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use crate::front_of_house::hosting::add_to_waitlist;
pub fn eat_at_restaurant() {
add_to_waitlist();
add_to_waitlist();
add_to_waitlist();
}
  • 함수의 부모 모듈을 use 키워드로 가져올 경우, 전체 경로 대신 축약 경로만 작성하면서도, 해당 함수가 현재 위치에 정의된 함수가 아님이 명확해지기 때문입니다.
  • 반면, 위의 예시는 add_to_waitlist 함수가 어디에 정의되어 있는지 알기 어렵습니다.

한편, use 키워드로 구조체나 열거형 등의 타 항목을 가져올 시에는 전체 경로를 작성하는 것이 보편적입니다.

Listing 7-14는 HashMap 표준 라이브러리 구조체를 바이너리 크레이트의 스코프로 가져오는 관용적인 코드 예시입니다. (std == standard)

use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert(1, 2);
}

이러한 관용이 탄생하게 된 명확한 이유는 없습니다. 어쩌다 보니 관습이 생겼고, 사람들이 이 방식대로 러스트 코드를 읽고 쓰는 데에 익숙해졌을 뿐입니다.

하지만, 동일한 이름의 항목을 여럿 가져오는 경우는 이 방식을 사용하지 않습니다. 러스트가 허용하기 않기 때문이죠.

이름이 같은 두 개의 타입을 동일한 스코프에 가져오려면 부모 모듈을 반드시 명시해야 합니다.

as 키워드로 새로운 이름 제공하기

use 키워드로 동일한 이름의 타입을 스코프로 여러 개 가져올 경우의 또 다른 해결 방법이 있습니다. 경로 뒤에 as 키워드를 작성하고, 새로운 이름이나 타입 별칭을 작성을 작성하면 됩니다.

use std::fmt::Result;
use std::io::Result as IoResult;
fn function1() -> Result {
// --snip--
Ok(())
}

fn function2() -> IoResult<()> {
// --snip--
Ok(())
}

두 번째 use 구문에서는, 앞서 스코프 내로 가져온 std::fmt 의 Result 와 충돌을 방지하기 위해 std::io::Result 타입의 이름을 IoResult 로 새롭게 지정합니다.

pub use 로 다시 내보내기

use 키워드로 이름을 가져올 경우, 해당 이름은 새 위치의 스코프에서 비공개가 됩니다. pub 와 use 를 결합하면 우리 코드를 호출하는 코드가, 해당 스코프에 정의된 것처럼 해당 이름을 참조할 수 있습니다. 이 기법은 항목을 스코프로 가져오는 동시에 다른 곳에서 항목을 가져갈 수 있도록 만들기 때문에, 다시 내보내기(Re-exporting) 라고 합니다.

mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
hosting::add_to_waitlist();
hosting::add_to_waitlist();
}

pub use를 사용하면 외부 코드에서 add_to_waitlist 함수를 hosting::add_to_waitlist 코드로 호출할 수 있습니다. pub use 로 지정하지 않을 경우, eat_at_restaurant 함수에서는 여전히 hosting::add_to_waitlist 로 호출할 수 있지만, 외부 코드에서는 불가능합니다.

pub use 를 사용하면 코드를 작성할 때의 구조와, 노출할 때의 구조를 다르게 만들 수 있습니다.

라이브러리를 제작하는 프로그래머와, 라이브러리를 사용하는 프로그래머 모두를 위한 라이브러리를 구성하는데 큰 도움이 되죠.

외부 패키지 사용하기

우린 2장에서 rand 라는 외부 패키지를 사용해 추리 게임의 랜덤 숫자 생성을 구현했었습니다. rand 패키지를 우리 프로젝트에서 사용하기 위해, Cargo.toml 에 다음 줄을 추가했었죠.

Cargo.toml 에 rand 를 의존성으로 추가하면 Cargo가 rand 패키지를 비롯한 모든 의존성을 crates.io에서 다운로드하므로 프로젝트 내에서 rand 패키지를 사용할 수 있게 됩니다.

그 후, use 키워드와 크레이트 이름인 rand를 작성하고, 가져올 항목을 나열하여, rand 정의를 우리가 만든 패키지의 스코프로 가져왔습니다. Rng 트레잇을 스코프로 가져오고 rand::thread_rng 함수를 호출했었습니다.

러스트 커뮤니티 구성원들은 crates.io에서 이용 가능한 다양한 패키지를 만들어두었으니, 같은 방식으로 가져와서 여러분의 패키지를 발전시켜보세요. 여러분이 만든 패키지의 Cargo.toml 파일에 추가하고, use 키워드를 사용해 스코프로 가져오면 됩니다.

알아 두어야 할 것은, 표준 라이브러리 std도 마찬가지로 외부 크레이트라는 겁니다. 러스트 언어에 포함되어 있기 때문에 Cargo.toml 에 추가할 필요는 없지만, 표준 라이브러리에서 우리가 만든 패키지의 스코프로 가져오려면 use 문을 작성해야 합니다. 예를 들어, HashMap을 가져오는 코드는 다음과 같습니다.

use std::collections::HashMap;

표준 라이브러리 크레이트의 이름인 std 로 시작하는 절대 경로입니다.

대량의 use 구문을 중첩 경로로 정리하기

동일한 크레이트나, 동일한 모듈 내에 정의된 항목을 여럿 사용할 경우, 각 항목당 한 줄씩 코드를 나열하면 수직 방향으로 너무 많은 영역을 차지합니다.

중첩 경로를 사용하면 한 줄로 작성할 수 있습니다. 경로의 공통된 부분을 작성하고, :: 와 중괄호 내에 경로가 각각 다른 부분을 나열합니다.

use std::cmp::Ordering;
use std::io;

use std::{cmp::Ordering, io};

중첩 경로는 경로의 모든 부위에서 사용할 수 있으며, 하위 경로가 동일한 use 구문이 많을 때 특히 빛을 발합니다.

다음 Listing 7-19는 두 use 구문의 예시입니다. 하나는 std::io 를 스코프로 가져오고, 다른 하나는 std::io::Write 를 스코프로 가져옵니다.

use std::io;
use std::io::Write;

use std::io::{self, Write};

글롭 연산자

경로에 글롭 연산자 *를 붙이면 경로 내 정의된 모든 공개 항목을 가져올 수 있습니다.

use std::collections::*;

이 use 구문은 std::collections 내에 정의된 모든 공개 항목을 현재 스코프로 가져옵니다. 하지만 글롭 연산자는 코드에서 사용된 어떤 이름이 어느 곳에 정의되어 있는지 파악하기 어렵게 만들 수 있으므로, 사용에 주의해야 합니다.

별개의 파일로 모듈 분리하기

이번 장에서 여태 나온 모든 예제들은 하나의 파일에 여러 모듈을 정의했습니다. 큰 모듈이라면, 정의를 여러 파일로 나누어 코드를 쉽게 찾아갈 수 있도록 만들어야 하겠죠.

각종 정의를 다른 파일로 이동했지만, 모듈 트리는 이전과 동일합니다. 거대한 모듈을 파일 하나에 전부 작성하지 않고, 필요에 따라 새로운 파일을 만들어 분리할 수 있도록 하는 것이 모듈 분리 기법입니다.

src/lib.rs 파일의 pub use crate::front_of_house::hosting 구문을 변경하지 않았으며, use 문이 크레이트의 일부로 컴파일 되는 파일에 영향을 주지 않는다는 점도 주목해 주세요. mod 키워드는 모듈을 선언하고, 러스트는 해당 모듈까지의 코드를 찾아서 모듈명과 동일한 이름의 파일을 찾습니다.

정리

러스트에서는 패키지를 여러 크레이트로 나눌 수 있고, 크레이트는 여러 모듈로 나눌 수 있습니다. 절대 경로나 상대 경로를 작성하여 어떤 모듈 내 항목을 다른 모듈에서 참조할 수 있습니다. 경로는 use 구문을 사용해 스코프 내로 가져와, 항목을 해당 스코프에서 여러 번 사용해야 할 때 더 짧은 경로를 사용할 수 있습니다. 모듈 코드는 기본적으로 비공개이지만, pub 키워드를 추가해 정의를 공개할 수 있습니다.

다음 장에서는 여러분의 깔끔하게 구성된 코드에서 사용할 수 있는 표준 라이브러리의 컬렉션 자료구조를 몇 가지 배워보겠습니다.

저급언어, 고급언어

· 14 min read
brown
FE developer

Intro

최근 이부분에 대해 정리하고 싶기도 했고,

최근에 애플은 왜 인텔 대신 ARM을 선택했나? 10분 순삭 해당 영상을 재밌게 봐서 한번 쭉 정리 한다.

여러 레퍼런스들이 이진코드와 기계어를 혼용해서 사용하는 느낌이다.

저급 언어(Low-Level Language)


  • 기계 중심의 언어
  • 실행 속도가 빠름
    • 이 부분은 사람이 작성한 코드가 컴파일러의 각종 최적화를 이겼을때나 가능...

기계어(Machine Language)


기계어는 2진법으로 구성된, 컴퓨터가 직접 해독하고 실행 할 수 있는 명령어 세트 프로그램을 나타내는 가장 낮은 단계의 언어 CPU에 따라 기계어가 다르다(각 기계마다 규약된 숫자들의 규칙 조합)

결국 프로그램이란 0과 1로 된, 컴퓨터에게 어떤 동작을 실행하라는 명령어들의 집합이고, 모든 언어들은 기계어로 변환된다.

  • 이 프로그램이란 것을 실행시키게 되면
  • 프로그램이라 불리는 명령어들이 메인 메모리(RAM 램)에 배치된다. 이 상태를 프로세스라고 부른다.
  • 이 배치된 명령어들을 하나씩 순서대로, 혹은 지정된 주소에 있는 명령어들을 읽어와서 CPU에서 계산 및 처리를 하게 되고
  • 명령어에 따라 CPU가 다른 컴퓨터 자원들의 동작, 수행을 명령한다.

바이너리 코드란?(Binary code)

  • 바이너리 코드는 컴퓨터가 인식할 수 있는 0과 1로 구성된 이진코드를 의미
  • 기계어는 0과 1로 이루어진 바이너리 코드이다.
  • 기계어가 이진코드로 이루어졌을 뿐이지 모든 이진코드가 기계어인 것은 아니다.(바이너리 코드 != 기계어)

바이트 코드란?(Byte Code)

  • CPU가 이해할 수 있는 언어가 기계어 라면 바이트 코드가상 머신에서 이해할 수 있는 이진 코드
  • 하드웨어가 아닌 소프트웨어에 의해 처리되기 때문에, 보통 기계어보다 더 추상적이다.
  • 역사적으로 바이트코드는 대부분의 명령 집합이 0개 이상의 매개 변수를 갖는 1byte 크기의 명령 코드(opcode)였기 때문에 바이트코드라 불리게 되었다.
  • 바이트코드는 특정 하드웨어에 대한 의존성을 줄이고, 인터프리팅도 쉬운 결과물을 생성하고자 하는 프로그래밍 언어에 의해, 출력 코드의 한 형태로 사용된다.
  • 컴파일되어 만들어진 바이트코드특정 하드웨어의 기계 코드를 만드는 컴파일러의 입력으로 사용되거나, 가상 머신에서 바로 실행된다.
  • Compile로 생성된 기계어 코드만으로 바로 실행되지는 않는 편
  • 대부분의 애플리케이션 로직에는 사용자가 직접 작성한 소스 코드 + 상당히 많은 라이브러리를 이용 혹은 만들어 활용 한다.
  • 이러한 라이브러리를 Application과 연결해 주는 작업 ==  링크(link)
  • 링크(link)여러 개의 오브젝트파일을 하나의 실행파일로 묶어주는 것이라고 보면 된다.
  • 운영체제에 관한 부분도 링크가 처리해준다.
  • 링크를 실행해주는 프로그램으로 링커(Linker)가 있다.
    • 정적링크(static link)
      • 컴파일된 소스파일을 연결해서 실행가능한 파일을 만드는 것
    • 동적링크(dynamic link)
      • 프로그램 실행 도중 외부에 존재하는 코드를 찾아서 연결

빌드(Build) = 컴파일(Compile) + 링크(Link)

빌드(Build): 컴파일(Compile)된 소스코드에 필요한 파일을 링크(Link) 시켜주는 것이다.

어셈블리어(Assembly language)


  • CPU 에는 해당 프로세서에 명령을 내리기 위해 고유의 명령어 세트가 마련되어 있는데 이 명령어 세트를 기계어라고 한다.
  • 이 기계어는 숫자들의 규칙조합임으로 프로그래밍에 상당히 난해하다.
  • 그래서 이 기계 명령어를 좀더 이해하기 쉬운 기호 코드로 나타낸것(기계어와 1:1로 대응된 명령을 기술하는 언어)이 어셈블리어이다.
  • 어셈블리어는 기계어를 알파벳으로 변환한 것이므로 어셈블리어를 배우는게 기계어를 배우는 것이다.
  • 어셈블리 언어는 그 코드가 어떤 일을 할지를 추상적이 아닌, 직접적으로 보여준다. 논리상의 오류나, 수행 속도, 수행 과정에 대해 명확히 해준다는 점에서 직관적인 언어이다.
  • 컴퓨터 시스템&구조를 좀 더 깊게 이해하고, 메모리상의 데이터나 I/O기기를 직접 액세스 하는등의 고급언어에서는 할 수 없는 조작을위해서이다. 프로그램의 최적화 및 리버스 엔지니어링을 위해서도 필요할 수 있다.
  • 어셈블리어를 기계어로 번역하는 프로그램이 제공되는데 이 프로그램을 어셈블러(assembleer)라고 한다.

고급언어(High-Level Language)


사람 중심의 언어

  • 실행을 위해서는 번역하는 과정이 필요함
  • 상이한 기계에서 소스 수정 없이 실행이 가능함

프로그래밍 언어의 문법 구조가 기계어와 유사하면 저급 언어(Low-Level Language)라고 부르고 사람들이 이해하기 편하도록 만들어진 프로그래밍 언어를 고급 언어라고 한다.

여기서 사용하는 High, Low의 의미는 사람의 언어에 가까운지, 기계의 언어에 가까운지를 표현하는 것일 뿐 성능의 좋고 나쁨을 이야기하는 용어가 아니다.

컴파일러(Compiler)


컴파일이란 어떤 언어의 코드를 다른 언어로 변환하는 과정이다.

원시 코드 -> 목적 코드

그리고 이것을 자동으로 수행해주는 소프트웨어를 컴파일러라고 한다.

  • compiler in general -> 고수준 언어를 저수준 언어로 변환
  • transcompiler or source-to-source compiler -> 비슷한 수준의 추상화를 가진 다른 언어로 변환 -> Transpile
    • 파스칼 ->  C
    • TypeScript -> JavaScript
    • 높은 버전의 자바스크립트 코드 -> 낮은 버전의 자바스크립트로 변환(Babel)
  • decompiler ->  저수준 언어고수준 언어로 변환

인터프리터(Interpreter)


소스코드를 바로 실행하는 프로그램 또는 환경을 말한다.

인터프리터는 다음의 과정 가운데 적어도 한 가지 기능을 가진 프로그램이다.

  1. 소스 코드를 직접 실행한다.
  2. 소스 코드를 효율적인 다른 중간 코드로 변환하고, 변환한 것을 바로 실행한다
  3. 인터프리터 시스템의 일부인 컴파일러가 만든, 미리 컴파일된 저장 코드의 실행을 호출한다.

인터프리터 언어는 원시코드를 기계어로 변환하는 과정없이 한줄 한줄 해석하여 바로 명령어를 실행하는 언어를 말합니다. R, Python, Ruby와 같은 언어들이 대표적인 인터프리터 언어입니다.

인터프리터가 직접 한 줄씩 읽고 따로 기계어로 변환하지 않기 때문에 빌드 시간이 없습니다. Runtime 상황에서는 한 줄씩 실시간으로 읽어서 실행하기 때문에 컴파일 언어에 비해 속도가 느립니다.

컴파일러 언어 vs 인터프리터 언어


고급언어로 작성된 프로그램들을 실행하는 데에는 두 가지 방법이 있다.

  • 가장 일반적인 방법은 프로그램을 컴파일 하는 것이고,
  • 다른 하나는 프로그램을 인터프리터에 통과시키는 방법이다.

컴파일러 언어

  • 전체 파일을 스캔하여 한꺼번에 번역
  • 초기 스캔시간이 오래 걸리지만, 한번 실행 파일이 만들어지고 나면 빠르다.
  • 기계어 번역과정에서 많은 메모리를 사용한다.
  • 전체 코드를 스캔하는 과정에서 모든 오류를 한꺼번에 출력해주기 때문에 실행 전에 오류를 알 수 있다.(컴파일 오류)

대표적인 언어로 C,C++,JAVA 등이 있다.

인터프리터 언어

  • 프로그램 실행시 한 번에 한 문장씩 번역한다.
  • 한번에 한문장씩 번역후 실행 시키기 때문에 실행 시간이 느리다.
  • 컴파일러와 같은 오브젝트 코드 생성과정이 없기 때문에 메모리 효율이 좋다.
  • 프로그램을 실행시키고 나서 오류를 발견하면 바로 실행을 중지 시킨다. 실행 후에 오류를 알 수 있기 때문에 사용성이 문제가 될수 있다.(런타임 오류)

대표적인 언어로 Python, Ruby, Javascript 등이 있다.


참조

Chap.6 enums and match

· 35 min read
brown
FE developer

이번 장에서는 열거(enumerations) 에 대해 살펴볼 것입니다. 열거형(enums) 이라고도 합니다.

열거형은 하나의 타입이 가질 수 있는 variant들을 열거함으로써 타입을 정의할 수 있도록 합니다.

  1. 우선, 하나의 열거형을 정의하고 사용해 봄으로써, 어떻게 열거형에 의미와 함께 데이터를 담을 수 있는지 보여줄 것입니다.
  2. 다음으로, Option 이라고 하는 특히 유용한 열거형을 자세히 볼 텐데, 이것은 어떤 값을 가질 수도 있고, 갖지 않을 수도 있습니다.
  3. 그 다음으로, 열거형의 값에 따라 쉽게 다른 코드를 실행하기 위해 match 표현식에서 패턴 매칭을 사용하는 방법을 볼 것입니다.
  4. 마지막으로, 코드에서 열거형을 편하고 간결하게 다루기 위한 관용 표현인 if let 구문을 다룰 것입니다.

열거형은 다른 언어들에서도 볼 수 있는 특징이지만, 열거형으로 할 수 있는 것들은 언어마다 다릅니다. p 러스트의 열거형은 F#, OCaml, Haskell 과 같은 함수형 언어의 대수 데이터 타입과 가장 비슷합니다.

6.1 열거형 정의하기

IP 주소를 다루는 프로그램을 만들어 보면서, 어떤 상황에서 열거형이 구조체보다 유용하고 적절한지 알아보겠습니다.

  1. 현재 사용되는 IP 주소 표준은 IPv4, IPv6 두 종류입니다(앞으로 v4, v6 로 표기하겠습니다).
  2. 즉, 우리가 만들 프로그램에서 다룰 IP 종류 역시 v4, v6 가 전부입니다.
  3. 이번엔 단 두 가지뿐이긴 하지만, 이처럼 가능한 모든 variant들을 죽 늘어놓는 것을 열거라고 표현합니다.
  4. IP 주소는 반드시 v4나 v6 중 하나만 될 수 있는데, 이러한 특성은 열거형 자료 구조에 적합합니다.
    1. 왜냐하면, 열거형의 값은 여러 variant 중 하나만 될 수 있기 때문입니다.
    2. v4, v6 는 근본적으로 IP 주소이기 때문에, 이 둘은 코드에서 모든 종류의 IP 주소에 적용되는 상황을 다룰 때 동일한 타입으로 처리되는 것이 좋습니다.
enum IpAddrKind {
V4,
V6,
}
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
route(IpAddrKind::V4);
route(IpAddrKind::V6);
}
fn route(ip_kind: IpAddrKind) {}

이제 IpAddrKind 은 우리의 코드 어디에서나 쓸 수 있는 데이터 타입이 되었습니다.

열거형 값

아래처럼 IpAddrKind 의 두 개의 variant 에 대한 인스턴스를 만들 수 있습니다:

    let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
  • 열거형을 정의할 때의 식별자로 네임스페이스가 만들어져서, 각 variant 앞에 콜론(:) 두 개를 붙여야 한다는 점을 알아두세요.
  • 이 방식은 IpAddrKind::V4IpAddrKind::V6 가 모두 IpAddrKind 타입이라는 것을 표현할 수 있다는 장점이 있습니다.

이제 IpAddrKind 타입을 인자로 받는 함수를 정의해봅시다:

fn route(ip_kind: IpAddrKind) {}

그리고, variant 중 하나를 사용해서 함수를 호출할 수 있습니다

route(IpAddrKind::V4);
route(IpAddrKind::V6);

열거형을 사용하면 이점이 더 있습니다. IP 주소 타입에 대해 더 생각해 볼 때, 지금으로써는 실제 IP 주소 데이터 를 저장할 방법이 없습니다. 단지 어떤 종류 인지만 알 뿐입니다.

5장에서 구조체에 대해 방금 공부했다고 한다면, 이 문제를 Listing 6-1에서 보이는 것처럼 풀려고 할 것입니다:

fn main() {
enum IpAddrKind {
V4,
V6,
}
struct IpAddr {
kind: IpAddrKind,
address: String,
}
let home = IpAddr {
kind: IpAddrKind::V4,
address: String::from("127.0.0.1"),
};
let loopback = IpAddr {
kind: IpAddrKind::V6,
address: String::from("::1"),
};
}

Listing 6-1: struct 를 사용해서 IP 주소의 데이터와 IpAddrKind variant 저장하기

  • IpAddrKind (이전에 정의한 열거형) 타입 kind 필드와 String 타입 address 필드를 갖는 IpAddr 를 정의하고, 인스턴스를 두 개 생성했습니다.
  • 첫 번째 home 은 kind 의 값으로 IpAddrKind::V4 을 갖고 연관된 주소 데이터로 127.0.0.1 를 갖습니다.
  • 두 번째 loopback 은 IpAddrKind 의 다른 variant 인 V6 을 값으로 갖고, 연관된 주소로 ::1 를 갖습니다.
  • kind 와 address 의 값을 함께 사용하기 위해 구조체를 사용했습니다.
  • 그렇게 함으로써 variant 가 연관된 값을 갖게 되었습니다.

각 열거형 variant 에 데이터를 직접 넣는 방식을 사용해서 열거형을 구조체의 일부로 사용하는 방식보다 더 간결하게 동일한 개념을 표현할 수 있습니다.

IpAddr 열거형의 새로운 정의에서는 두 개의 V4 와 V6 variant 는 연관된 String 타입의 값을 갖게 됩니다:

    enum IpAddr {
V4(String),
V6(String),
}
let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));

열거형의 각 variant 에 직접 데이터를 붙임으로써, 구조체를 사용할 필요가 없어졌습니다.

  • 구조체 대신 열거형을 사용할 때의 또 다른 장점이 있습니다.
  • 각 variant 는 다른 타입과 다른 양의 연관된 데이터를 가질 수 있습니다.
  • v4 타입의 IP 주소는 항상 0 ~ 255 사이의 숫자 4개로 된 구성요소를 갖게 될 것입니다.
  • V4 주소에 4개의 u8 값을 저장하길 원하지만, v6 주소는 하나의 String 값으로 표현되길 원한다면, 구조체로는 이렇게 할 수 없습니다.
  • 열거형은 이런 경우를 쉽게 처리합니다
    enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
  • 두 가지 다른 종류의 IP 주소를 저장하기 위해 코드상에서 열거형을 정의하는 몇 가지 방법을 살펴봤습니다.
  • 그러나, 누구나 알듯이 IP 주소와 그 종류를 저장하는 것은 흔하기 때문에, 표준 라이브러리에 사용할 수 있는 정의가 있습니다! 표준 라이브러리에서 IpAddr 를 어떻게 정의하고 있는지 살펴봅시다.
  • 위에서 정의하고 사용했던 것과 동일한 열거형과 variant 를 갖고 있지만, variant 에 포함된 주소 데이터는 두 가지 다른 구조체로 되어 있으며, 각 variant 마다 다르게 정의하고 있습니다:
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --생략--
}
struct Ipv6Addr {
// --생략--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}

이 코드로 알 수 있듯, 열거형 variant 에는 어떤 종류의 데이터건 넣을 수 있습니다. 문자열, 숫자 타입, 구조체 등은 물론, 다른 열거형마저도 포함할 수 있죠!

이건 여담이지만, 러스트의 표준 라이브러리 타입은 여러분 생각보다 단순한 경우가 꽤 있습니다.

현재 스코프에 표준 라이브러리를 가져오지 않았기 때문에, 표준 라이브러리에 IpAddr 정의가 있더라도, 동일한 이름의 타입을 만들고 사용할 수 있습니다.

열거형의 다른 예제를 살펴봅시다. 이 예제에서는 각 variant 에 다양한 유형의 타입들이 포함되어 있습니다:

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

이 열거형에는 다른 데이터 타입을 갖는 네 개의 variant 가 있습니다:

  • Quit 은 연관된 데이터가 전혀 없습니다.
  • Move 은 익명 구조체를 포함합니다.
  • Write 은 하나의 String 을 포함합니다.
  • ChangeColor 는 세 개의 i32 을 포함합니다.

variant 로 열거형을 정의하는 것은 다른 종류의 구조체들을 정의하는 것과 비슷합니다. 열거형과 다른 점은 struct 키워드를 사용하지 않는다는 것과 모든 variant 가 Message 타입으로 그룹화된다는 것입니다. 아래 구조체들은 이전 열거형의 variant 가 갖는 것과 동일한 데이터를 포함할 수 있습니다:

struct QuitMessage; // unit struct
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

각기 다른 타입을 갖는 여러 개의 구조체를 사용한다면, 이 메시지 중 어떤 한 가지를 인자로 받는 함수를 정의하기 힘들 것입니다. Listing 6-2 에 정의한 Message 열거형은 하나의 타입으로 이것이 가능합니다.

  • 열거형과 구조체는 한 가지 더 유사한 점이 있습니다.
  • 구조체에 impl 을 사용해서 메소드를 정의한 것처럼, 열거형에도 정의할 수 있습니다.
  • 여기 Message 열거형에 에 정의한 call 이라는 메소드가 있습니다:
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
// method body would be defined here
}
}
let m = Message::Write(String::from("hello"));
m.call();
}

열거형의 값을 가져오기 위해 메소드 안에서 self 를 사용할 것입니다.

이 예제에서 생성한 변수 m 은 Message::Write(String::from("hello")) 값을 갖게 되고, 이 값은 m.call()이 실행될 때, call 메소드 안에서 self가 될 것입니다.

이제 표준 라이브러리에 포함된 열거형 중 유용하고 굉장히 자주 사용되는 Option 열거형을 살펴봅시다:

Option 열거형이 Null 값 보다 좋은 점들

이번 절에서는 표준 라이브러리에서 열거형으로 정의된 또 다른 타입인 Option 에 대한 사용 예를 살펴볼 것입니다.

  • Option 타입은 많이 사용되는데, 값이 있거나 없을 수도 있는 아주 흔한 상황을 나타내기 때문입니다.

  • 이 개념을 타입 시스템의 관점으로 표현하자면, 컴파일러가 발생할 수 있는 모든 경우를 처리했는지 체크할 수 있습니다.

  • 이렇게 함으로써 버그를 방지할 수 있고, 이것은 다른 프로그래밍 언어에서 매우 흔합니다.

  • 러스트는 다른 언어들에서 흔하게 볼 수 있는 null 개념이 없습니다.

    • Null 은 값이 없다는 것을 표현하는 하나의 값입니다.
  • null 개념이 존재하는 언어에서, 변수의 상태는 둘 중 하나입니다.

  • null 인 경우와, null 이 아닌 경우죠.

  • null 값으로 발생하는 문제는, null 값을 null 이 아닌 값처럼 사용하려고 할 때 여러 종류의 오류가 발생할 수 있다는 것입니다.

  • 하지만, "현재 어떠한 이유로 인해 유효하지 않거나, 존재하지 않는 하나의 값"이라는 null 이 표현하려고 하는 개념은 여전히 유용합니다.

null 의 문제는 실제 개념에 있기보다, 특정 구현에 있습니다. 이와 같이 러스트에는 null 이 없지만, 값의 존재 혹은 부재의 개념을 표현할 수 있는 열거형이 있습니다. 이 열거형은 Option<T> 이며, 다음과 같이 표준 라이브러리에 정의되어 있습니다:

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

Option<T> 열거형은 너무나 유용하기 때문에, 러스트에서 기본으로 임포트하는 목록인 prelude 에도 포함돼있습니다. 따라서 명시적으로 가져올 필요가 없으며, SomeNone variant 앞에 Option:: 도 붙이지 않아도 됩니다.

여러모로 특별하긴 하지만 Option<T> 는 여전히 일반적인 열거형이며, Some(T)None 도 여전히 Option<T> 의 variant 입니다.

<T> 는 러스트의 문법이며 아직 다루지 않았습니다. 제너릭 타입 파라미터이며, 제너릭에 대해서는 10 장에서 더 자세히 다룰 것입니다. 지금은 단지 <T> 가 Option 열거형의 Some variant 가 어떤 타입의 데이터라도 가질 수 있다는 것을 의미한다는 것을 알고 있으면 됩니다. 여기 숫자 타입과 문자열 타입을 갖는 Option 값에 대한 예들이 있습니다:

    let some_number = Some(5);
let some_string = Some("a string");

let absent_number: Option<i32> = None;

Some 이 아닌 None 을 사용한다면, Option<T> 이 어떤 타입을 가질지 러스트에게 알려줄 필요가 있습니다. 컴파일러는 None 만 보고는 Some variant 가 어떤 타입인지 추론할 수 없습니다.

  • Some 값을 얻게 되면, 값이 존재한다는 것과 해당 값이 Some 내에 있다는 것을 알 수 있습니다.
  • None 값을 얻게 되면, 얻은 값이 유효하지 않은 값이라는, 어떤 면에서는 null 과 같은 의미를 갖습니다.
  • 그렇다면 왜 Option<T> 가 null 을 갖는 것보다 나을까요? 간단하게 말하면, Option<T> 와 T는 다른 타입이며, 컴파일러는 Option<T> 값을 명확하게 유효한 값처럼 사용하지 못하도록 합니다.
  • T 에 대한 연산을 수행하기 전에 Option<T> 를 T 로 변환해야 합니다.
  • 이런 방식은 null 로 인해 발생하는 가장 흔한 문제인, 실제로는 null 인데 null 이 아니라고 가정하는 상황을 발견하는 데 도움이 됩니다.

null 이 아닌 값을 갖는다는 가정을 놓치는 경우에 대해 걱정할 필요가 없게 되면, 코드에 더 확신을 갖게 됩니다. null 일 수 있는 값을 사용하기 위해서, 명시적으로 값의 타입을 Option<T> 로 만들어 줘야 합니다. 그다음엔 값을 사용할 때 명시적으로 null 인 경우를 처리해야 합니다. 값의 타입이 Option<T> 가 아닌 모든 곳은 값이 null 이 아니라고 안전하게 가정할 수 있습니다. 이것은 null을 너무 많이 사용하는 문제를 제한하고 러스트 코드의 안정성을 높이기 위한 러스트의 의도된 디자인 결정 사항입니다.

그래서, Option<T> 타입인 값을 사용할 때 Some variant 에서 T 값을 가져오려면 어떻게 해야 하냐고요?

  • Option<T> 열거형이 가진 메소드는 많고, 저마다 다양한 상황에서 유용하게 쓰일 수 있습니다.
  • 그러니 한번 문서에서 여러분에게 필요한 메소드를 찾아보세요. Option<T> 의 여러 메소드를 익혀두면 앞으로의 러스트 프로그래밍에 매우 많은 도움이 될 겁니다.
  • 일반적으로, Option<T> 값을 사용하기 위해서는 각 variant 를 처리할 코드가 필요할 겁니다.
  • Some(T) 값일 때만 실행돼서 내부의 T 값을 사용할 코드도 필요할 테고, None 값일 때만 실행될, T 값을 쓸 수 없는 코드도 필요할 겁니다.
  • match 라는 제어 흐름을 구성하는 데 쓰이는 표현식을 열거형과 함께 사용하면 이런 상황을 해결할 수 있습니다.
  • 열거형의 variant 에 따라서 알맞은 코드를 실행하고, 해당 코드 내에선 매칭된 값의 데이터를 사용할 수 있죠.

6.2 match 흐름 제어 연산자

러스트는 match라고 불리는 흐름 제어 연산자를 가지고 있는데 이는 우리에게 일련의 패턴에 대해 어떤 값을 비교한 뒤 어떤 패턴에 매치되었는지를 바탕으로 코드를 수행하도록 해줍니다.

match 표현식을 동전 분류기와 비슷한 종류로 생각해보세요. 동전들은 다양한 크기의 구멍들이 있는 트랙으로 미끄러져 내려가고, 각 동전은 그것에 맞는 첫 번째 구멍을 만났을 때 떨어집니다. 동일한 방식으로, 값들은 match 내의 각 패턴을 통과하고, 해당 값에 "맞는" 첫 번째 패턴에서, 그 값은 실행 중에 사용될 연관된 코드 블록 안으로 떨어질 것입니다.

enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
  • value_in_cents 함수 내의 match를 쪼개 봅시다.
  • 먼저, match 키워드 뒤에 표현식을 써줬는데, 위의 경우에는 coin 값입니다.
  • 이는 if 에서 사용하는 표현식과 매우 유사하지만, 큰 차이점이 있습니다.
    • if 를 사용할 경우에는 표현식에서 boolean 값을 반환해야 하지만, 여기서는 어떤 타입이든 가능합니다.
  • 다음은 match 갈래(arm)들입니다.
    • 하나의 갈래는 패턴과 코드 두 부분으로 이루어져 있습니다.
    • 여기서의 첫 번째 갈래는 값 Coin::Penny로 되어있는 패턴을 가지고 있고 그 후에 패턴과 실행되는 코드를 구분해주는 => 연산자가 있습니다.
    • 위의 경우에서 코드는 그냥 값 1입니다.
    • 각 갈래는 그다음 갈래와 쉼표로 구분됩니다.
  • match 표현식이 실행될 때, 결과 값을 각 갈래의 패턴에 대해서 순차적으로 비교합니다.
  • 만일 어떤 패턴이 그 값과 매치되면, 그 패턴과 연관된 코드가 실행됩니다.
  • 만일 그 패턴이 값과 매치되지 않는다면, 동전 분류기와 비슷하게 다음 갈래로 실행을 계속합니다.

값들을 바인딩하는 패턴들

match의 또 다른 유용한 기능은 패턴과 매치된 값들의 부분을 바인딩할 수 있다는 것입니다.

한 가지 예로서, 우리의 열거형 variant 중 하나를 내부에 값을 들고 있도록 바꿔봅시다.

  • 1999년부터 2008년까지, 미국은 각 50개 주마다 한쪽 면의 디자인이 다른 쿼터 동전을 주조했습니다.
  • 다른 동전들은 주의 디자인을 갖지 않고, 따라서 오직 쿼터 동전들만 이 특별 값을 갖습니다.
  • 우리는 이 정보를 Quarter variant 내에 UsState 값을 포함하도록 우리의 enum을 변경함으로써 추가할 수 있습니다.
  1. 우리의 친구가 모든 50개 주 쿼터 동전을 모으기를 시도하는 중이라고 상상해봅시다.
  2. 동전의 종류에 따라 동전을 분류하는 동안,
  3. 우리는 또한 각 쿼터 동전에 연관된 주의 이름을 외쳐서,
  4. 만일 그것이 우리 친구가 가지고 있지 않은 것이라면,
  5. 그 친구는 자기 컬렉션에 그 동전을 추가할 수 있겠지요.

이 코드를 위한 매치 표현식 내에서는 variant Coin::Quarter의 값과 매치되는 패턴에 state라는 이름의 변수를 추가합니다. Coin::Quarter이 매치될 때, state 변수는 그 쿼터 동전의 주에 대한 값에 바인드 될 것입니다. 그러면 우리는 다음과 같이 해당 갈래에서의 코드 내에서 state를 사용할 수 있습니다:

#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn value_in_cents(coin: Coin) -> u8 {
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::Quarter(UsState::Alaska));
}
  1. 만일 우리가 value_in_cents(Coin::Quarter(UsState::Alaska))를 호출했다면,
  2. coin은 Coin::Quarter(UsState::Alaska)가 될 테지요.
  3. 각각의 매치 갈래들과 이 값을 비교할 때, Coin::Quarter(state)에 도달할 때까지 아무것도 매치되지 않습니다.
  4. 이 시점에서, state에 대한 바인딩은 값 UsState::Alaska가 될 것입니다.
  5. 그러면 이 바인딩을 println! 표현식 내에서 사용할 수 있고,
  6. 따라서 Quarter에 대한 Coin 열거형 variant로부터 내부의 주에 대한 값을 얻었습니다.

Option<T> 를 이용하는 매칭

이전 절에서 Option<T> 값을 사용하려면 Some 일 때 실행돼서, Some 내의 T 값을 얻을 수 있는 코드가 필요하다고 했었죠. 이제 Coin 열거형을 다뤘던 것처럼 Option<T> 도 match 로 다뤄보도록 하겠습니다. 동전들을 비교하는 대신, Option<T>의 variant를 비교할 것이지만, match 표현식이 동작하는 방법은 동일하게 남아있습니다.

Option<i32>를 파라미터로 받아서, 내부에 값이 있으면, 그 값에 1을 더하는 함수를 작성하고 싶다고 칩시다. 만일 내부에 값이 없으면, 이 함수는 None 값을 반환하고 다른 어떤 연산도 수행하는 시도를 하지 않아야 합니다.

fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}

plus_one의 첫 번째 실행을 좀 더 자세히 시험해봅시다.

  1. plus_one(five)가 호출될 때, plus_one의 본체 내의 변수 x는 값 Some(5)를 갖게 될 것입니다.

  2. 그런 다음 각각의 매치 갈래에 대하여 이 값을 비교합니다.

    1. Some(5) 값은 패턴 None과 매칭되지 않으므로, 다음 갈래로 계속 갑니다.
  3. Some(5)가 Some(i)랑 매칭되나요?

    1. 예, 바로 그렇습니다! 동일한 variant를 갖고 있습니다.
    2. Some 내부에 담긴 값은 i에 바인드 되므로, i는 값 5를 갖습니다.
    3. 그런 다음 매치 갈래 내의 코드가 실행되므로,
    4. i의 값에 1을 더한 다음 최종적으로 6을 담은 새로운 Some 값을 생성합니다.
  4. 이제 x가 None인 Listing 6-5에서의 plus_one의 두 번째 호출을 살펴봅시다.

  5. match 안으로 들어와서 첫 번째 갈래와 비교합니다.

    1. None => None,
  6. 매칭되었군요! 더할 값은 없으므로, 프로그램은 멈추고 =>의 우측 편에 있는 None 값을 반환합니다.

match와 열거형을 조합하는 것은 다양한 경우에 유용합니다. 여러분은 러스트 코드 내에서 이러한 패턴을 많이 보게 될 겁니다. 열거형에 대한 match, 내부의 데이터에 변수 바인딩, 그런 다음 그에 대한 수행 코드 말이지요. 처음에는 약간 까다롭지만, 여러분이 일단 익숙해지면, 이를 모든 언어에서 쓸 수 있게 되기를 바랄 것입니다. 이것은 꾸준히 사용자들이 가장 좋아하는 기능입니다.

_ Placeholder

러스트는 또한 우리가 모든 가능한 값을 나열하고 싶지 않을 경우에 사용할 수 있는 패턴을 가지고 있습니다. 이럴 땐 _ 라는 특별한 패턴을 사용하면 됩니다:

fn main() {
let some_u8_value = 0u8;
match some_u8_value {
1 => println!("one"),
3 => println!("three"),
5 => println!("five"),
7 => println!("seven"),
_ => (),
}
}
  • _ 패턴은 어떠한 값과도 매칭될 것입니다.
  • 우리의 다른 갈래 뒤에 이를 집어넣음으로써, _는 그전에 명시하지 않은 모든 가능한 경우에 대해 매칭될 것입니다.
  • ()는 단지 단윗값이므로, _ 케이스에서는 어떤 일도 일어나지 않을 것입니다.
  • 결과적으로, 우리가 _ placeholder 이전에 나열하지 않은 모든 가능한 값들에 대해서는 아무것도 하고 싶지 않다는 것을 말해줄 수 있습니다.

하지만 match 표현식은 우리가 단 한 가지 경우에 대해 고려하는 상황에서는 다소 장황할 수 있습니다. 이러한 상황을 위하여, 러스트는 if let을 제공합니다.

if let을 사용한 간결한 흐름 제어

if let 문법은 if와 let을 조합하여 하나의 패턴만 매칭시키고 나머지 경우는 무시하는 값을 다루는 덜 수다스러운 방법을 제공합니다. 어떤 Option<u8> 값을 매칭 하지만 그 값이 3일 경우에만 코드를 실행시키고 싶어 하는 Listing 6-6에서의 프로그램을 고려해 보세요:

fn main() {
let some_u8_value = Some(0u8); // some with 숫자에 타입 주는 방법
match some_u8_value {
Some(3) => println!("three"),
_ => (),
}
}

if let을 이용하여 이 코드를 더 짧게 쓸 수 있습니다.

fn main() {
let some_u8_value = Some(0u8);
if let Some(3) = some_u8_value {
println!("three");
}
}

if let은 =로 구분된 패턴과 표현식을 입력받습니다. 이는 match와 동일한 방식으로 작동하는데, 여기서 표현식은 match에 주어지는 것이고 패턴은 이 match의 첫 번째 갈래와 같습니다.

  • if let을 이용하는 것은 여러분이 덜 타이핑하고, 덜 들여쓰기 하고, 보일러 플레이트 코드를 덜 쓰게 된다는 뜻입니다.
  • 하지만, match가 강제했던 하나도 빠짐없는 검사를 잃게 되었습니다.
  • match와 if let 사이에서 선택하는 것은 여러분의 특정 상황에서 여러분이 하고 있는 것에 따라, 그리고 간결함을 얻는 것이 전수 조사를 잃는 것에 대한 적절한 거래인지에 따라 달린 문제입니다.

즉 if let 은, 한 패턴에 매칭될 때만 코드를 실행하고 다른 경우는 무시하는 match 문을 작성할 때 사용하는 syntax sugar 라고 생각하시면 됩니다.

if let과 함께 else를 포함시킬 수 있습니다. else 뒤에 나오는 코드 블록은 match 표현식에서 _ 케이스 뒤에 나오는 코드 블록과 동일합니다.

정리

  • 지금까지 우리는 열거한 값들의 집합 중에서 하나가 될 수 있는 커스텀 타입을 만들기 위해서 열거형을 사용하는 방법을 다뤄보았습니다.
  • 우리는 표준 라이브러리의 Option<T> 타입이 에러를 방지하기 위해 어떤 식으로 타입 시스템을 이용하도록 도움을 주는지 알아보았습니다.
  • 열거형 값들이 내부에 데이터를 가지고 있을 때는, match나 if let을 이용하여 그 값들을 추출하고 사용할 수 있는데, 둘 중 어느 것을 이용할지는 여러분이 다루고 싶어 하는 경우가 얼마나 많은지에 따라 달라집니다.

여러분은 이제 구조체와 열거형을 이용해 원하는 개념을 표현할 수 있습니다. 또한, 여러분의 API 내에 커스텀 타입을 만들어서 사용하면, 작성한 함수가 원치 않는 값으로 작동하는 것을 컴파일러가 막아주기 때문에 타입 안정성도 보장받을 수 있습니다.

사용하기 직관적이고 여러분의 사용자가 필요로 할 것만 정확히 노출된 잘 조직화된 API를 여러분의 사용들에게 제공하기 위해서, 이제 러스트의 모듈로 넘어갑시다.

Chap.5 Structs

· 35 min read
brown
FE developer
  • 구조체(struct)는 여러 값을 묶어서 어떤 의미를 갖는 데이터 단위를 정의하는 데에 사용합니다.
  • 객체지향 언어에 익숙하신 분들은 구조체를 "객체가 갖는 데이터 속성"과 같은 개념으로 이해하셔도 좋습니다.
  • 이번 장에선 앞서 배운 튜플과 구조체 간 비교, 구조체 사용법, 구조체의 데이터와 연관된 동작을 표현하는 메소드, 연관함수(associated functions) 정의 방법을 다룹니다.
  • 필요한 데이터 형식을 작성할 때 구조체나 열거형(6장에서 배울 예정입니다)을 이용하면, 여러분이 직접 만든 타입에도 러스트의 컴파일 시점 타입 검사 기능을 최대한 활용할 수 있습니다.

5.1 구조체 정의 및 인스턴트화


  • 구조체는 3장에서 배운 튜플과 비슷합니다.
  • 튜플처럼 구조체의 구성 요소들은 각각 다른 타입이 될 수 있습니다.
  • 그리고 여기에 더해서, 구조체는 각각의 구성 요소에 이름을 붙일 수 있습니다.
  • 따라서 각 요소가 더 명확한 의미를 갖게 되고, 특정 요소에 접근할 때 순서에 의존할 필요도 사라집니다.
  • 결론적으로, 튜플보다 유연하게 사용할 수 있습니다.

구조체를 정의할 땐 struct 키워드와 해당 구조체에 지어줄 이름을 입력하면 됩니다. 이때 구조체 이름은 함께 묶을 데이터의 의미에 맞도록 지어주세요. 이후 중괄호 안에서는 필드(field)라 하는 각 구성 요소의 이름 및 타입을 정의합니다.

struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
  • 정의한 구조체를 사용하려면 해당 구조체 내 각 필드의 값을 정해 인스턴스(instance)를 생성해야 합니다.
  • 구조체 정의는 대충 해당 구조체에 무엇이 들어갈지를 정해둔 양식이며, 인스턴스는 거기에 실제 값을 넣은 것이라고 생각하시면 됩니다. 예시로 확인해 보죠
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}

fn main() {
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
}
user1.email = String::from("anotheremail@example.com");
fn build_user(email: String, username: String) -> User {
User {
email: email,
username: username,
active: true,
sign_in_count: 1,
}
}
  • 구조체 내 특정 값은 점(.) 표기법으로 얻어올 수 있습니다. 사용자의 이메일 주소를 얻어야 한다치면 user1.email 처럼 사용할 수 있죠.
  • 변경 가능한 인스턴스라면, 같은 방식으로 특정 필드의 값을 변경할 수도 있습니다.
  • 가변성은 해당 인스턴스 전체가 지니게 됩니다.(일부 필드만 변경 가능하도록 만들 수는 없음)
  • 구조체도 다른 표현식과 마찬가지로 함수 마지막 표현식에서 암묵적으로 새 인스턴스를 생성하고 반환할 수 있습니다.

변수명과 필드명이 같을 때 간단하게 필드 초기화하기

변수명과 구조체 필드명이 같을 땐, 필드 초기화 축약법(field init shorthand)을 사용해서 더 적은 타이핑으로 같은 기능을 구현할 수 있습니다.

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

기존 인스턴스를 이용해 새 인스턴스를 만들 때 구조체 갱신법 사용하기

기존에 있던 인스턴스에서 대부분의 값을 유지한 채로 몇몇 값만 바꿔 새로운 인스턴스를 생성하게 되는 경우가 간혹 있습니다. 그럴 때 유용한 게 바로 구조체 갱신법(struct update syntax)입니다.

struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
fn main() {
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
// 구조체 갱신법 X
let user2 = User {
email: String::from("another@example.com"),
username: String::from("anotherusername567"),
active: user1.active,
sign_in_count: user1.sign_in_count,
};
// 구조체 갱신법 O
let user2 = User {
email: String::from("another@example.com"),
username: String::from("anotherusername567"),
..user1
};
}

필드명이 없는, 타입 구분용 튜플 구조체

  • 구조체를 사용해 튜플과 유사한 형태의 튜플 구조체(tuple structs)를 정의할 수도 있습니다.
  • 튜플 구조체는 필드의 이름을 붙이지 않고 필드 타입 만을 정의하며, 구조체 명으로 의미를 갖는 구조체입니다.
  • 이는 튜플 전체에 이름을 지어주거나 특정 튜플을 다른 튜플과 구분 짓고 싶은데, 그렇다고 각 필드명을 일일이 정해 일반적인 구조체를 만드는 것은 배보다 배꼽이 더 큰 격이 될 수 있을 때 유용합니다.

튜플 구조체의 정의는 일반적인 구조체처럼 struct 키워드와 구조체 명으로 시작되나, 그 뒤에는 타입들로 이루어진 튜플이 따라옵니다.

fn main() {
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
}
  • blackorigin 이 서로 다른 튜플 구조체의 인스턴스이므로, 타입이 서로 달라진다는 점이 중요합니다. 구조체 내 필드 구성이 같더라도 각각의 구조체는 별도의 타입이기 때문이죠.
  • 즉, Color 타입과 Point 타입은 둘 다 i32 값 3 개로 이루어진 타입이지만, Color 타입을 매개변수로 받는 함수에 Point 타입을 인자로 넘겨주는 건 불가능합니다.

필드가 없는 유사 유닛 구조체

  • 필드가 아예 없는 구조체를 정의할 수도 있습니다.
  • 유닛 타입인 () 과 비슷하게 동작하므로 유사 유닛 구조체(unit-like structs) 라 지칭
  • 어떤 타입을 내부 데이터 저장 없이 10장에서 배울 트레잇을 구현하기만 하는 용도로 사용할 때 주로 활용됩니다.

구조체 데이터의 소유권

  • User 구조체 정의에서는 의도적으로 &str 문자열 슬라이스 대신 구조체가 소유권을 갖는 String 타입을 사용했습니다.  - 구조체 인스턴스가 유효한 동안 인스턴스 내의 모든 데이터가 유효하도록 만들기 위해서죠.  - 참조자를 이용해 구조체가 소유권을 갖지 않는 데이터도 저장할 수는 있지만,  - 이는 10장에서 배울 라이프타임(lifetime)을 활용해야 합니다.  - 라이프타임을 사용하면 구조체가 존재하는 동안에 구조체 내 참조자가 가리키는 데이터의 유효함을 보장받을 수 있기 때문이죠.

  • 만약 라이프타임을 명시하지 않고 참조자를 저장하고자 하면 다음처럼 문제가 발생합니다.

  • 위 에러를 해결하여 구조체에 참조자를 저장하는 방법은 10장에서 알아볼 겁니다.

  • 지금 당장은 &str 대신 String 을 사용함으로써 넘어가도록 하죠.


5.2 구조체를 사용한 예제 프로그램


어떨 때 구조체를 사용하면 좋을지를, 사각형 넓이를 계산하는 프로그램을 작성하면서 익혀보도록 합시다.

아래 예제는 함수와 rect의 연관성 X

fn main() {
let width1 = 30;
let height1 = 50;
println!(
"The area of the rectangle is {} square pixels.",
area(width1, height1)
);
}
fn area(width: u32, height: u32) -> u32 {
width * height
}

튜플로 리팩토링하기

튜플을 사용함으로써 더 짜임새 있는 코드가 됐고, 인자도 단 하나만 넘기면 된다는 점에선 프로그램이 발전했다고 볼 수 있습니다. 하지만 각 요소가 이름을 갖지 않는 튜플의 특성 때문에 값을 인덱스로 접근해야 해서 계산식이 난잡해졌네요.

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
}

구조체로 리팩토링하여 코드에 더 많은 의미를 담기

구조체는 데이터에 이름표를 붙여서 의미를 나타낼 수 있습니다.

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
}
  • area 함수의 매개변수는 이제 rectangle 하나뿐입니다.
  • 단, 구조체의 소유권을 가져와 버리면 main 함수에서 area 함수 호출 이후에 rect1 을 더 사용할 수 없으므로rectangle 매개변수의 타입을 불변 참조자 타입으로 정하여 소유권을 빌려오기만 하도록 만들었습니다.
  • 불변 참조자 타입이니 함수 시그니처와 호출시에 & 를 작성합니다.
  • area 함수는 Rectangle 인스턴스의 widthheight 필드에 접근하여 area, 즉 넓이를 계산합니다.
  • 이제 함수 시그니처만 봐도 의미를 정확히 알 수 있네요. widthheight 가 서로 연관된 값이라는 것도 알 수 있고,
  • 0 이나 1 대신 필드명을 사용해 더 명확하게 구분할 수 있습니다.

트레잇 derive 로 유용한 기능 추가하기

  • println! 매크로에는 여러 출력 형식을 사용할 수 있습니다.

  • 그리고 기본 형식인 {} 로 지정할 땐 Display 라는, 최종 사용자를 위한 출력 형식을 사용하죠.

  • 여태 사용했던 기본 타입들은 Display 가 기본적으로 구현돼있었습니다. 1 같은 기본 타입들을 사용자에게 보여줄 수 있는 형식은 딱 한 가지뿐이니까요.

  • 하지만 구조체라면 이야기가 달라집니다. 중간중간 쉼표를 사용해야 할 수도 있고, 중괄호도 출력해야 할 수도 있고, 필드 일부를 생략해야 할 수도 있는 등 여러 경우가 있을 수 있습니다.

  • 러스트는 이런 애매한 상황에 우리가 원하는 걸 임의로 예상해서 제공하려 들지 않기 때문에, 구조체에는 Display 구현체가 기본 제공되지 않습니다.

  • 따라서 println! 에서 처리할 수 없죠.

  • println! 매크로 호출을 println!("rect1 is {:?}", rect1); 으로 바꿔봅시다.

  • {} 내에 :? 를 추가하는 건 println! 에 Debug 라는 출력 형식을 사용하고 싶다고 전달하는 것과 같습니다.

  • 이 Debug 라는 트레잇은 최종 사용자가 아닌, 개발자에게 유용한 방식으로 출력하는, 즉 디버깅할 때 값을 볼 수 있게 해주는 트레잇입니다.

  • 러스트는 디버깅 정보를 출력하는 기능을 자체적으로 가지고 있습니다.

  • 하지만 우리가 만든 구조체에 해당 기능을 적용하려면 명시적인 동의가 필요하므로, 구조체 정의 바로 이전에 #[derive(Debug)] 어노테이션을 작성해주어야 합니다.

    • Annotation은 프로그램 내에서 주석과 유사하게, 프로그래밍 언어에는 영향을 미치지 않으면서 프로그램/프로그래머에게 유의미한 정보를 제공하는 역할을 한다.
    • 컴파일러가 특정 오류를 억제하도록 지시하는 것과 같이 프로그램 코드의 일부가 아닌 프로그램에 관한 데이터를 제공, 코드에 정보를 추가하는 정형화된 방법.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {:?}", rect1);
}
  • 가장 예쁜 출력 형태라 할 수는 없지만, 인스턴스 내 모든 필드 값을 보여주므로 디버깅하는 동안에는 확실히 유용할 겁니다.
  • 필드가 더 많은 구조체라면 이보다 더 읽기 편한 형태가 필요할 텐데요.
  • 그럴 땐 println! 문자열 내에 {:?} 대신 {:#?} 를 사용하면 됩니다.

러스트에선 이처럼 derive 어노테이션으로 우리가 만든 타입에 유용한 동작을 추가할 수 있는 트레잇을 여럿 제공합니다. 이들 목록 및 각각의 동작은 부록 C에서 확인할 수 있으니 참고해주세요. 또한, 여러분만의 트레잇을 직접 만들고, 이런 트레잇들의 동작을 원하는 대로 커스터마이징 해서 구현하는 방법은 10장에서 배울 예정입니다.

본론으로 돌아옵시다. 우리가 만든 area 함수는 사각형의 면적만을 계산하며, Rectangle 구조체를 제외한 다른 타입으로는 작동하지 않습니다. 그렇다면 아예 Rectangle 구조체와 더 밀접하게 만드는 편이 낫지 않을까요? 다음에는 area 함수를 Rectangle 타입 내에 메소드(method) 형태로 정의하여 코드를 리팩토링하는 방법을 알아보겠습니다.


5.3 메소드 문법


  • 메소드(method) 는 함수와 유사합니다.
  • fn 키워드와 함수명으로 선언하고, 매개변수와 반환 값을 가지며, 다른 어딘가로부터 호출될 때 실행됩니다.
  • 하지만 메소드는 함수와 달리 구조체 문맥상에 정의되고(열거형이나 트레잇 객체 안에 정의되기도 하며, 이는 각각 6장, 17장에서 알아보겠습니다), 첫 번째 매개변수가 항상 self 라는 차이점이 있습니다.
    • self 매개변수는 메소드가 호출된 구조체 인스턴스를 나타냅니다.

메소드 정의

기존의 Rectangle 매개변수를 갖던 area 함수를 Rectangle 구조체에 정의된 area 메소드로 바꿔봅시다.

#[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()
);
}
  1. Rectangle 내에 함수를 정의하기 위해서
  2. impl (구현: implementation) 블록을 만들고
  3. area 함수를 옮겨온 후,
  4. 기존에 있던 첫 번째 매개변수를 (이 경우엔 유일한 매개변수네요) 함수 시그니처 및 본문 내에서 찾아 self 로 변경했습니다.
  5. 그리고 main 함수 내에선 rect1 을 인수로 넘겨 area 함수를 호출하는 대신, 메소드 문법(method syntax) 을 사용해 Rectangle 인스턴스의 area 메소드를 호출했습니다.
  6. 메소드 문법은 차례대로 인스턴스, 점, 메소드명, 괄호 및 인수로 구성됩니다.
  • area 시그니처부터 살펴보도록 하겠습니다.

  • 기존의 rectangle: &Rectangle 이 &self 로 바뀌었네요.

  • Rectangle 을 명시하지 않아도 되는 이유는, 메소드가 impl Rectangle 내에 있다는 점을 이용해 러스트가 self 의 타입을 알아낼 수 있기 때문입니다.

  • 또한 self 앞에 기존 &Rectangle 처럼 & 가 붙은 점을 주목해주세요.

  • 메소드는 지금처럼 self 를 변경 불가능하게 빌릴 수도 있고, 다른 매개변수처럼 변경 가능하도록 빌릴 수도 있고, 아예 소유권을 가져올 수도 있습니다.

  • &self 를 사용해 변경 불가능하게 빌려온 이유는 기존에 &Rectangle 을 사용했던 이유와 같습니다.

  • 우리가 원하는 건 소유권을 가져오는 것도, 데이터를 작성하는 것도 아닌, 데이터를 읽는 것뿐이니까요.

  • 만약 메소드에서 호출된 인스턴스를 변경해야 한다면? &mut self 를 사용하면 됩니다.

  • self 로만 작성하여 인스턴스의 소유권을 가져오도록 만드는 일은 거의 없습니다.

    • 그나마 self 를 다른 무언가로 변형시키고, 그 이후에는 원본 인스턴스 사용을 막고자 할 때나 종종 볼 수 있죠.

함수 대신 메소드를 이용했을 때의 주요 이점은 메소드 시그니처 내에서 self 의 타입을 반복해서 입력하지 않아도 된다는 것뿐만이 아닙니다.

코드를 더 조직적으로 만들 수 있죠. 우리가 라이브러리를 제공하게 된다고 가정해봅시다. 향후 우리가 제공한 라이브러리를 사용할 사람들이 Rectangle 에 관련된 코드를 라이브러리 곳곳에서 찾아내야 하는 것과, impl 블록 내에 모두 모아둔 것 중에 어떤 것이 나을까요? 답은 명확합니다.

-> 연산자는 없나요?

C 나 C++ 언어에서는 메소드 호출에 두 종류의 연산자가 쓰입니다. 어떤 객체의 메소드를 직접 호출할 땐 . 를 사용하고, 어떤 객체의 포인터를 이용해 메소드를 호출하는 중이라서 역참조가 필요할 땐 -> 를 사용하죠. 예를 들어서 object 라는 포인터가 있다면, object->something() 는 (*object).something() 로 나타낼 수 있습니다.

이 -> 연산자와 동일한 기능을 하는 연산자는 러스트에 없습니다. 러스트에는 자동 참조 및 역참조(automatic referencing and dereferencing) 라는 기능이 있고, 메소드 호출에 이 기능이 포함돼있기 때문입니다.

여러분이 object.something() 코드로 메소드를 호출하면, 러스트에서 자동으로 해당 메소드의 시그니처에 맞도록 &&mut* 를 추가합니다. 즉, 다음 두 표현은 서로 같은 표현입니다: p1.distance(&p2); (&p1).distance(&p2);

첫 번째 표현이 더 깔끔하죠? 이런 자동 참조 동작은 메소드의 수신자(self 의 타입을 말합니다)가 명확하기 때문에 가능합니다. 수신자와 메소드명을 알면 해당 메소드가 인스턴스를 읽기만 하는지(&self), 변경하는지(&mut self), 소비하는지(self) 러스트가 알아낼 수 있거든요. 또한 메소드의 수신자를 러스트에서 암묵적으로 빌린다는 점은, 실사용 환경에서 소유권을 인간공학적으로 만드는 중요한 부분입니다.

더 많은 파라미터를 가진 메소드

Rectangle 구조체의 두 번째 메소드를 구현하여 메소드 사용법을 연습해 봅시다.

  • 새로 만들 메소드는 다른 Rectangle 인스턴스를 받아서,
  • self 사각형 면적 내에 두 번째 사각형 Rectangle 인스턴스가 들어갈 수 있다면 true 를 반환하고,
  • 못 들어가면 false 를 반환할 겁니다.
  • 즉, can_hold 메소드를 정의하여 프로그램이 작동하도록 만들겠습니다:
    • 메소드의 정의는 impl Rectangle 블록 내에 위치할 것이고,
    • 메소드명은 can_hold, 매개변수는 Rectangle 을 불변 참조자로 받겠죠.
    • 이때 매개변수 타입은 메소드를 호출하는 코드를 보면 알아낼 수 있습니다.
    • rect1.can_hold(&rect2) 에서 Rectangle 인스턴스 rect2 의 불변 참조자인 &rect2 를 전달했으니까요.
    • rect2 를 읽을 수만 있으면 되기 때문에 변경 가능하게 빌려올 필요도 없으며, rect2 의 소유권을 main 에 남겨두지 않을 이유도 없으니, 논리적으로도 불변 참조자가 가장 적합합니다.
    • 반환값은 Boolean 타입이 될 거고, self 의 너비, 높이가 다른 Rectangle 의 너비, 높이보다 큰지 검사하는 식으로 구현될 겁니다.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
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,
};
let rect3 = Rectangle {
width: 60,
height: 45,
};
println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

연관 함수

  • impl 블록에는 self 매개변수를 갖지 않는 함수도 정의할 수 있습니다.
  • 이러한 함수는 구조체의 인스턴스로 동작하는 것이 아니기 때문에 메소드는 아니지만,
  • 구조체와 연관돼있기에 연관 함수(associated functions) 라고 부릅니다.
  • 여러분이 이미 사용해본 연관 함수로는 String::from 이 대표적이군요.

연관 함수는 주로 새로운 구조체 인스턴스를 반환해주는 생성자로 활용됩니다. 예시를 들어보죠.

  • Rectangle 로 정사각형을 만들 때 너비, 높이에 같은 값을 두 번 명시하는 대신,
  • 치수 하나를 매개변수로 받고 해당 치수로 너비 높이를 설정하는 연관함수를 만들어,
  • 더 간단하게 정사각형을 만드는 방법을 제공해보겠습니다:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn square(size: u32) -> Rectangle {
Rectangle {
width: size,
height: size,
}
}
}
fn main() {
let sq = Rectangle::square(3);
}

연관 함수를 호출할 땐 let sq=Rectangle::square(3); 처럼 구조체 명에 :: 구문을 붙여서 호출합니다.

연관 함수는 구조체의 네임스페이스 내에 위치하기 때문이죠. :: 구문은 7장에서 알아볼 모듈에 의해 생성되는 네임스페이스에도 사용됩니다.

impl 블록은 여러 개일 수 있습니다

각 구조체는 여러 개의 impl 블록을 가질 수 있습니다. 다음 Listing 5-16은 Listing 5-15에 나온 코드를 변경해 impl 블록을 여러 개로 만든 모습입니다:

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

위 코드에서는 impl 블록을 여러 개로 나눠야 할 이유가 전혀 없지만, impl 블록을 반드시 하나만 작성해야 할 필요는 없음을 보여드리기 위해 예시로 작성했습니다. 여러 impl 블록을 유용하게 사용하는 모습은 제네릭 타입 및 트레잇 내용을 다루는 10장에서 보실 수 있습니다.

요약

  • 구조체를 사용하면 우리에게 필요한 의미를 갖는 타입을 직접 만들 수 있습니다.
  • 또한, 구조체를 사용함으로써 서로 관련 있는 데이터들을 하나로 묶어 관리할 수 있으며,
  • 각 데이터 조각에 이름을 붙여 코드를 더 명확하게 만들 수 있습니다.
  • 메소드를 이용하면 여러분이 만든 구조체의 인스턴스에 특정한 동작을 지정해 줄 수도 있고,
  • 연관 함수로 인스턴스가 아닌 구조체 네임스페이스를 대상으로 기능을 추가할 수도 있습니다.

하지만, 구조체로만 커스텀 타입을 만들 수 있는 건 아닙니다. 다음엔 열거형을 배워서 여러분이 쓸 수 있는 도구를 하나 더 늘려보도록 합시다.


codewars

0907

Number of People in the Bus

fn number(bus_stops: &[(i32, i32)]) -> i32 {
let mut come = 0;
let mut out = 0;
for peoples in bus_stops {
let (push, pop) = peoples;
come += push;
out += pop;
}
come - out
}
//
fn number(bus_stops:&[(i32,i32)]) -> i32 {
bus_stops.iter().fold(0,|acc,x| acc + x.0 - x.1)
}
fn number(bus_stops:&[(i32,i32)]) -> i32 {
bus_stops
.into_iter()
.map(|n| n.0 - n.1)
.sum()
}

0910

Testing 1-2-3

fn number(lines: &[&str]) -> Vec<String> {
lines
.iter()
.enumerate()
.map(|(i, &item)| {
let mut str = String::from("");
let idx = i + 1;
let s = format!("{}: {}", idx, item);
str.push_str(&s);
str
})
.collect::<Vec<String>>()
}
//
fn number(lines: &[&str]) -> Vec<String> {
lines.iter().enumerate().map(|x| format!("{}: {}", x.0 + 1, x.1)).collect()
}
fn number(lines: &[&str]) -> Vec<String> {
lines.iter().zip(1..).map(|(x, i)| format!("{i}: {x}")).collect()
}
fn number(lines: &[&str]) -> Vec<String> {
lines.iter().enumerate().map(|(n, line)| format!("{}: {line}", n + 1)).collect()
}

Complementary DNA

fn dna_strand(dna: &str) -> String {
let mut ans = String::from("");
dna.chars().for_each(|c| match c {
'A' => ans.push_str("T"),
'T' => ans.push_str("A"),
'G' => ans.push_str("C"),
_ => ans.push_str("G"),
});
ans
}
//
fn dna_strand(dna: &str) -> String {
dna.chars().map(|c|
match c {
'A' => 'T',
'T' => 'A',
'G' => 'C',
'C' => 'G',
_ => c,
})
.collect()
}
use std::collections::HashMap;

fn dna_strand(dna: &str) -> String {
let complements: HashMap<char, char> = [('A', 'T'), ('T', 'A'), ('C', 'G'), ('G', 'C')].iter().cloned().collect();
dna.chars().map(|c| complements.get(&c).unwrap()).collect()
}

Maximum Length Difference

into_iter, iter의 차이 https://sftblw.tistory.com/91

fn mx_dif_lg(a1: Vec<&str>, a2: Vec<&str>) -> i32 {
if a1.len() == 0 || a2.len() == 0 {
-1
} else {
let a_map1 = a1.into_iter().map(|x| x.len() as i32);
let a_map2 = a_map1.clone();
let b_map1 = a2.into_iter().map(|x| x.len() as i32);
let b_map2 = b_map1.clone();
//
let x_max = a_map1.max().unwrap();
let x_min = a_map2.min().unwrap();
let y_max = b_map1.max().unwrap();
let y_min = b_map2.min().unwrap();

let a = (x_max - y_min).abs();
let b = (y_max - x_min).abs();
if a > b {
a
} else {
b
}
}
}
//
use std::cmp::{max, min};
use std::usize::MAX;

pub fn mx_dif_lg(a1: Vec<&str>, a2: Vec<&str>) -> i32 {
if a1.is_empty() || a2.is_empty() {
return -1;
}
let (max1, min1) = a1
.iter()
.map(|&x| x.len())
.fold((0, MAX), |ac, x| (max(ac.0, x), min(ac.1, x)));
let (max2, min2) = a2
.iter()
.map(|&x| x.len())
.fold((0, MAX), |ac, x| (max(ac.0, x), min(ac.1, x)));

max(((max1 - min2) as i32).abs(), ((max2 - min1) as i32).abs())
}
fn mx_dif_lg(a1: Vec<&str>, a2: Vec<&str>) -> i32 {
if a1.len() == 0 || a2.len() == 0 { return -1 }
let a1_min = a1.iter().map(|s| s.len()).min().unwrap() as i32;
let a1_max = a1.iter().map(|s| s.len()).max().unwrap() as i32;
let a2_min = a2.iter().map(|s| s.len()).min().unwrap() as i32;
let a2_max = a2.iter().map(|s| s.len()).max().unwrap() as i32;
(a1_max - a2_min).max(a2_max - a1_min)
}
fn mx_dif_lg(a1: Vec<&str>, a2: Vec<&str>) -> i32 {
// your code
a1.iter().flat_map(|s1| a2.iter().map(|s2| (s1.len() as i32 - s2.len() as i32).abs()).collect::<Vec<_>>() ).max().unwrap_or(-1)
}

Odd or Even?

fn odd_or_even(numbers: Vec<i32>) -> String {
let sum: i32 = numbers.iter().sum();
if sum % 2 == 0 {
"even".to_string()
} else {
"odd".to_string()
}
}
//
fn odd_or_even(numbers: Vec<i32>) -> String {
match numbers.iter().sum::<i32>() % 2 == 0 {
true => "even".to_string(),
false => "odd".to_string()
}
}
fn odd_or_even(numbers: Vec<i32>) -> String {
(if numbers.iter().sum::<i32>() % 2 == 0 {"even"} else {"odd"}).to_string()
}

Check the exam


fn check_exam(arr_a: &[&str], arr_b: &[&str]) -> i64 {
let ans = arr_a
.iter()
.enumerate()
.map(|(idx, val)| {
if arr_b[idx] == "" {
0
} else if &arr_b[idx] == val {
4
} else {
-1
}
})
.sum();
if ans < 0 {
0
} else {
ans
}
}
//
fn check_exam(arr_a: &[&str], arr_b: &[&str]) -> i64 {
arr_a.iter().zip(arr_b.iter()).fold(0, |pts, ans| {
match ans {
(a, b) if a == b => pts + 4,
(_, b) if b == &"" => pts,
_ => pts - 1
}
}).max(0)
}
fn check_exam(arr_a: &[&str], arr_b: &[&str]) -> i64 {
arr_a
.iter()
.zip(arr_b)
.map(|(&a, &b)| match b {
"" => 0,
_ if a == b => 4,
_ => -1,
})
.sum::<i64>()
.max(0)
}

Highest and Lowest

fn high_and_low(numbers: &str) -> String {
let vec = numbers.split(" ").map(|x| x.parse::<i32>().unwrap());
let vec2 = vec.clone();
let min = vec.min().unwrap();
let max = vec2.max().unwrap();
format!("{} {}", max, min)
}
//
fn high_and_low(numbers: &str) -> String {
use std::cmp;
let f = |(max, min), x| (cmp::max(max, x), cmp::min(min, x));

let answer = numbers
.split_whitespace()
.map(|x| x.parse::<i32>().unwrap())
.fold((i32::min_value(), i32::max_value()), f);
format!("{} {}", answer.0, answer.1)
}
extern crate itertools;
use itertools::Itertools;

fn high_and_low(numbers: &str) -> String {
let (min, max): (i32, i32) = numbers
.split_whitespace()
.map(|s| s.parse().unwrap())
.minmax()
.into_option()
.unwrap();
format!("{} {}", max, min)
}
fn high_and_low(numbers: &str) -> String {
let as_ints: Vec<i32> = numbers.split(" ").map(|x| x.parse().unwrap()).collect();
format!("{} {}", as_ints.iter().max().unwrap(), as_ints.iter().min().unwrap())
}

Sum of Minimums!


fn sum_of_minimums(numbers: [[u8; 4]; 4]) -> u8 {
numbers
.map(|arr| arr.into_iter().min().unwrap())
.iter()
.sum()
}
//
fn sum_of_minimums(numbers: [[u8; 4]; 4]) -> u8 {
numbers.iter().map(|row| row.iter().min().unwrap()).sum()
}
fn sum_of_minimums(numbers: [[u8; 4]; 4]) -> u8 {
numbers.iter().filter_map(|v| v.iter().min()).sum()
}
fn sum_of_minimums(numbers: [[u8; 4]; 4]) -> u8 {
numbers.iter().flat_map(|x| x.iter().min()).sum()
}

Chap.4 ownership

· 50 min read
brown
FE developer

소유권(Ownership)은 가비지 컬렉터가 없는 러스트에서 메모리 안정성을 보장하는 비결입니다.

이는 러스트에서 가장 특별한 기능이며, 어떻게 동작하는지 반드시 이해해 둬야 합니다.

따라서 이번 장에서는 소유권을 비롯해 소유권과 관련된 빌림(Borrowing), 슬라이스(Slice) 기능과 러스트에선 데이터를 메모리에 어떻게 저장하는지 알아보겠습니다.

4.1 소유권이 뭔가요?


  • 모든 프로그램은 작동하는 동안 컴퓨터의 메모리 사용 방법을 관리해야 합니다.
  • 몇몇 언어는 가비지 컬렉션으로 프로그램에서 더 이상 사용하지 않는 메모리를 끊임없이 찾는 방식을 채용했고, 다른 언어는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제하는 방식을 택했습니다.
  • 이때 러스트는 제 3의 방식을 택했습니다. '소유권(ownership)' 이라는 시스템을 만들고, 컴파일러가 컴파일 중 검사할 여러 규칙을 정해 메모리를 관리하는 방식이죠.
  • 이 방식은 프로그램 실행 속도에 악영향을 줄 일이 없습니다. 컴파일 타임에 전부 해결하니까요.

스택, 힙 영역

러스트 같은 시스템 프로그래밍 언어에서는 값을 스택에 저장하느냐 힙에 저장하느냐의 차이가 프로그램의 동작 및 프로그래머의 의사 결정에 훨씬 큰 영향을 미칩니다.

스택, 힙 둘 다 여러분이 작성한 프로그램이 런타임 중 이용할 메모리 영역이라는 공통점이 있지만 구조는 각각 다릅니다.

  • 스택은 값이 들어온 순서대로 저장하고, 역순으로 제거합니다. 이를 last in, fist out 이라 하죠
    • 스택에 저장되는 데이터는 모두 명확하고 크기가 정해져 있어야 합니다.
  • 컴파일 타임에 크기를 알 수 없거나, 크기가 변경될 수 있는 데이터는 스택 대신 힙에 저장됩니다.
    • 힙은 스택보다 복잡합니다.
    • 데이터를 힙에 넣을때 먼저 저장할 공간이 있는지 운영체제한테 물어봅니다. 그럼 메모리 할당자는 커다란 힙 영역 안에서 어떤 빈 지점을 찾고, 이 지점은 사용 중이라고 표시한 뒤 해당 지점을 가리키는 포인터(pointer) 를 우리한테 반환합니다. 이 과정을 힙 공간 할당(allocating on the heap), 줄여서 할당(allocation) 이라 합니다 (스택에 값을 푸시하는 것은 할당이라 부르지 않습니다). 포인터는 크기가 정해져 있어 스택에 저장할 수 있으나, 포인터가 가리키는 실제 데이터를 사용하고자 할 때는 포인터를 참조해 해당 포인터가 가리키는 위치로 이동하는 과정을 거쳐야 합니다.
  • 스택 영역은 데이터에 접근하는 방식상 힙 영역보다 속도가 빠릅니다.
  • 메모리 할당자가 새로운 데이터를 저장할 공간을 찾을 필요가 없이 항상 스택의 가장 위에 데이터를 저장하면 되기 때문이죠.
  • 반면에 힙에 공간을 할당하는 작업은 좀 더 많은 작업을 요구하는데, 메모리 할당자가 데이터를 저장하기에 충분한 공간을 먼저 찾고 다음 할당을 위한 준비를 위해 예약을 수행해야 하기 때문입니다.
  • 힙 영역은 포인터가 가리키는 곳을 찾아가는 과정으로 인해 느려집니다.
  • 현대 프로세서는 메모리 내부를 이리저리 왔다 갔다 하는 작업이 적을수록 속도가 빨라지는데, 힙에 있는 데이터들은 서로 멀리 떨어져 있어 프로세서가 계속해서 돌아다녀야 하기 때문이죠.
  • 힙 영역처럼 데이터가 서로 멀리 떨어져 있으면 작업이 느려지고, 반대로 스택 영역처럼 데이터가 서로 붙어 있으면 작업이 빨라집니다. 이외에도, 큰 공간을 할당하는 작업도 힙 영역의 속도를 늦추는 요인입니다.
  • 여러분이 함수를 호출하면, 호출한 함수에 넘겨준 값(값 중엔 힙 영역의 데이터를 가리키는 포인터도 있을 수 있습니다)과 해당 함수의 지역 변수들이 스택에 푸시됩니다.
  • 그리고 이 데이터들은 함수가 종료될 때 pop 됩니다.
  • 코드 어느 부분에서 힙의 어떤 데이터를 사용하는지 추적하고, 힙에서 중복되는 데이터를 최소화하고, 쓰지 않는 데이터를 힙에서 정리해 영역을 확보하는 등의 작업은 모두 소유권과 관련되어 있습니다.
  • 반대로 말하면 여러분이 소유권을 한번 이해하고 나면 스택, 힙 영역으로 고민할 일이 줄어들 거란 뜻이지만, 소유권의 존재 이유가 힙 데이터의 관리라는 점을 알고 있으면 소유권의 동작 방식을 이해하는데에 도움이 됩니다.

소유권 규칙

소유권 규칙부터 알아보겠습니다.

  • 러스트에서, 각각의 값은 소유자(owner) 라는 변수가 정해져 있다.
  • 한 값의 소유자는 동시에 여럿 존재할 수 없다.
  • 소유자가 스코프 밖으로 벗어날 때, 값은 버려진다(dropped).

변수의 스코프(Scope)

스코프란, 프로그램 내에서 개체가 유효한 범위를 말합니다.

let s = "hello";

변수 s는 문자열 리터럴을 나타내며, 문자열 리터럴의 값은 코드 내에 하드코딩되어 있습니다.

이 변수는 선언된 시점부터 현재의 스코프를 벗어날 때까지 유효합니다.

중요한 점은 두 가지입니다.

  1. s 가 스코프 내에 나타나면 유효합니다.
  2. 유효기간은 스코프 밖으로 벗어나기 전까지 입니다.

String 타입

소유권 규칙을 설명하려면 3장 "데이터 타입들" 에서 다룬 타입보다 복잡한 타입이 필요합니다.

앞서 다룬 것들은 전부 스택에 저장되고 스코프를 벗어날 때 pop 되는 타입이지만, 이번에 필요한 건 힙에 저장되면서, 러스트의 데이터 정리과정을 알아보는 데 적합한 타입이거든요.

따라서 String 타입을 예제로 활용하되, 여기서 String 타입을 전부 설명할 순 없으므로 자세한 내용은 8장에서 다루고, 이번 장에선 소유권 관련 부분에만 집중하겠습니다.

이러한 관점은 다른 표준 라이브러리나 여러분이 만들 복잡한 데이터 타입에도 적용됩니다.

  • 여태 보아온 문자열은 코드 내에 하드코딩하는 방식의 '문자열 리터럴(string literal)'이었습니다.
  • 문자열 리터럴은 쓰기 편리하지만, 만능은 아닙니다.
  • 그 이유는 문자열 리터럴이 불변성(immutable)을 지니기에 변경할 수 없다는 점과, 프로그램에 필요한 모든 문자열을 우리가 프로그래밍하는 시점에 알 수는 없다는 점 때문입니다.
  • 사용자한테 문자열을 입력받아 저장하는 기능 등을 만들어야 하는 상황에선 문자열 리터럴을 사용할 수 없죠.
  • 따라서 러스트는 또 다른 문자열 타입인 String 을 제공합니다.
  • 이 타입은 힙에 할당되기 때문에, 컴파일 타임에 크기를 알 수 없는 텍스트도 저장할 수 있습니다.
  • String 타입은 다음과 같이 from 함수와 문자열 리터럴을 이용해 생성 가능합니다.
let s = String::from("hello");

이중 콜론(::)은 우리가 함수를 사용할 때, string_from 같은 함수명 대신 String 타입 하위라는 것을 특정해서 함수를 호출할 수 있도록 하려고 사용하는 네임스페이스 연산자입니다.

메소드 관련 문법은 5장 “메소드 문법” 에서 자세히 다루며, 모듈 및 네임스페이스는 7장 “경로를 사용해 모듈 트리에서 항목 가리키기” 에서 다루고 있습니다.

이 String 문자열은 변경 가능합니다:

let mut s = String::from("hello");
s.push_str(", world!"); // push_str() appends a literal to a String
println!("{}", s); // This will print `hello, world!` ``

하지만, 문자열 리터럴과 String 에 무슨 차이가 있길래 어떤 것은 변경할 수 있고 어떤 것은 변경할 수 없을까요?

차이점은 각 타입의 메모리 사용 방식에 있습니다.

메모리와 할당

  • 문자열 리터럴은 컴파일 타임에 내용을 알 수 있으므로, 텍스트가 최종 실행파일에 하드코딩됩니다.
  • 이 방식은 빠르고 효율적이지만, 문자열이 변하지 않을 경우에만 사용할 수 있습니다.
  • 컴파일 타임에 크기를 알 수 없는 텍스트는 바이너리 파일에 집어넣을 수 없죠.

반면 String 타입은 힙에 메모리를 할당하는 방식을 사용하기 때문에 텍스트 내용 및 크기를 변경할 수 있습니다. 하지만 이는 다음을 의미하기도 합니다:

  • 실행 중 메모리 할당자로부터 메모리를 요청해야 합니다.
  • String 사용을 마쳤을 때 메모리를 해제할 (할당자에게 메모리를 반납할) 방법이 필요합니다.

이 중 첫 번째는 이미 우리 손으로 해결했습니다. String::from 호출 시, 필요한 만큼 메모리를 요청하도록 구현되어 있거든요. 프로그래밍 언어 사이에서 일반적으로 사용하는 방식이죠.

하지만 두 번째는 다릅니다.

  • 가비지 콜렉터 (garbage collector, GC) 를 갖는 언어에선 GC가 사용하지 않는 메모리를 찾아 없애주므로 프로그래머가 신경 쓸 필요 없으나,
  • GC가 없는 언어에선 할당받은 메모리가 필요 없어지는 지점을 프로그래머가 직접 찾아 메모리 해제 코드를 작성해야 합니다. 굉장히 어려운 일이죠.
  • 프로그래머가 놓친 부분이 있다면 메모리 낭비가 발생하고, 메모리 해제 시점을 잘못 잡으면 버그가 생깁니다.
  • 두 번 해제할 경우도 마찬가지로 버그가 발생하겠죠.
  • 따라서 우린 할당(allocate) 과 해제(free) 가 하나씩 짝짓도록 만들어야 합니다.

이 문제를 러스트에선 변수가 자신이 소속된 스코프를 벗어나는 순간 자동으로 메모리를 해제하는 방식으로 해결했습니다.

예시로 보여드리도록 하죠. Listing 4-1 에서 문자열 리터럴을 String 으로 바꿔보았습니다:

{
let s = String::from("hello"); // s is valid from this point forward
// do stuff with s
}
// this scope is now over, and s is no
// longer valid`
  • 보시면 String 에서 사용한 메모리를 자연스럽게 해제하는 지점이 있습니다.
  • s 가 스코프 밖으로 벗어날 때인데, 러스트는 변수가 스코프 밖으로 벗어나면 drop 이라는 특별한 함수를 호출합니다.
  • 이 함수는 개발자가 직접 메모리 해제 코드를 작성해 넣을 수 있게 되어있으며, 이 경우 String 개발자가 작성한 메모리 해제 코드가 실행되겠죠.
  • drop 은 닫힌 중괄호 } 가 나타나는 지점에서 자동으로 호출됩니다.

Note: C++ 에선 이런 식으로 객체의 수명이 끝나는 시점에 리소스를 해제하는 패턴을 Resource Acquisition Is Initialization (RAII) 라 합니다. RAII 패턴에 익숙하신 분들이라면 러스트의 drop 함수가 친숙할지도 모르겠네요.

  • 이 패턴은 러스트 코드를 작성하는 데 깊은 영향을 미칩니다.
  • 지금은 단순해 보이지만, 힙 영역을 사용하는 변수가 많아져 상황이 복잡해지면 코드가 예기치 못한 방향으로 동작할 수도 있죠.

변수와 데이터 간 상호작용 방식: 이동(move)

러스트에선 동일한 데이터에 여러 변수가 서로 다른 방식으로 상호작용할 수 있습니다.

정수형을 이용한 예제로 살펴보겠습니다.

  • let x = 5; let y = x;
  • x의 정숫값을 y에 대입
    • 5 를 x 에 바인드(bind)하고,
    • x 값의 복사본을 만들어 y 에 바인드
    • 그럼 xy 두 변수가 생길 겁니다. 각각의 값은 5 가 되겠죠.
    • 실제로도 이와 같은데, 정수형 값은 크기가 정해진 단순한 값이기 때문입니다.
    • 이는 다시 말해, 두 5 값은 스택에 push 된다는 뜻입니다.

이번엔 앞선 예제를 String 으로 바꿔보았습니다:

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

이전 코드와 매우 비슷하니, 동작 방식도 같을 거라고 생각하실 수도 있습니다.

두 번째 줄에서 s1 의 복사본을 생성해 s2 에 바인딩하는 식으로 말이죠. 하지만 이번엔 전혀 다른 방식으로 동작합니다.

String 은 그림 좌측에서 나타나듯, 문자열 내용이 들어 있는 메모리를 가리키는 포인터, 문자열 길이, 메모리 용량 세 부분으로 이루어져 있습니다.

이 데이터는 스택에 저장되며, 우측의 문자열 내용은 힙에 저장됩니다.

메모리 속 String 의 모습

s1 에 바인드된, "hello" 값을 저장하고 있는 String 의 메모리 속 모습

문자열 길이와 메모리 용량이 무슨 차이인가 궁금하실 분들을 위해 간단히 설명해드리자면,

  • 문자열 길이는 String 의 내용이 현재 사용하고 있는 메모리를 바이트 단위로 나타낸 것이고,

  • 메모리 용량은 메모리 할당자가 String 에 할당한 메모리의 양을 뜻합니다.

  • 이번 내용에서는 길이, 용량 사이의 차이는 중요한 내용이 아니니, 이해가 잘 안 되면 용량 값은 무시하셔도 좋습니다.

  • s2 에 s 을 대입하면 String 데이터가 복사됩니다.

  • 이때 데이터는 스택에 있는 데이터, 즉 포인터, 길이, 용량 값을 말하며, 포인터가 가리키는 힙 영역의 데이터는 복사되지 않습니다. 즉, 다음과 같은 메모리 구조를 갖게 됩니다.

s1, s2 는 동일한 데이터를 가리킵니다

Figure 4-2: 변수 s2 가 s1 의 포인터, 길이, 용량 값을 복사했을 때 나타나는 메모리 구조

다음 Figure 4-3 은 힙 메모리 상 데이터까지 복사했을 경우 나타날 구조로, 실제로는 이와 다릅니다. 만약 러스트가 이런 식으로 동작한다면, 힙 내 데이터가 커질수록 s2 = s1 연산은 굉장히 느려질겁니다.

s1 and s2 to two places

Figure 4-3: 러스트에서 힙 데이터까지 복사할 경우의 s2 = s1 연산 결과

앞서 언급한 내용 중 변수가 스코프 밖으로 벗어날 때 러스트에서 자동으로 drop 함수를 호출하여 해당 변수가 사용하는 힙 메모리를 제거한다는 내용이 있었습니다.

하지만 Figure 4-2 처럼 두 포인터가 같은 곳을 가리킬 경우에는 어떻게 될까요?

s2s1 이 스코프 밖으로 벗어날 때 각각 메모리를 해제하게 되면 중복 해제(double free) 오류가 발생할 겁니다.

이는 메모리 안정성 버그 중 하나이며, 보안을 취약하게 만드는 메모리 손상의 원인입니다.

따라서, 러스트에는 여러 포인터가 한 곳을 가리킬 경우를 대비한 규칙이 존재합니다.

  • s1 에 할당한 메모리를 새로 복사하는 대신, 기존의 s1 을 무효화하는 것이죠.
  • 이로써 러스트는 s1 이 스코프를 벗어나더라도 아무것도 해제할 필요가 없어집니다.
  • 그럼 s2 가 만들어진 이후에 s1 을 사용하면 어떻게 될까요?
  • 결론부터 말씀드리면, 사용할 수 없습니다:

여러분이 다른 프로그래밍 언어에서 “얕은 복사(shallow copy)”, “깊은 복사(deep copy)” 라는 용어를 들어보셨다면, 힙 데이터를 복사하지 않고 포인터, 길이, 용량 값만 복사하는 것을 얕은 복사라고 생각하셨을 수도 있지만,

러스트에선 기존의 변수를 무효화하기 때문에 이를 얕은 복사가 아닌 이동(move) 이라 하고, 앞선 코드는 s1 이 s2 로 이동되었다 라고 표현합니다.

s1 이 s2 로 이동됨

Figure 4-4: s1 이 무효화 된 후의 메모리 구조

이로써 문제가 사라졌네요! s2 만이 유효하니, 스코프 밖으로 벗어나면 그대로 자신의 메모리를 해제하면 됩니다.

덧붙이자면, 러스트는 절대 자동으로 “깊은 복사” 로 데이터를 복사하는 일이 없습니다. 따라서, 러스트가 자동으로 수행하는 모든 복사는 런타임 성능면에서 효율적이라 할 수 있습니다.

변수와 데이터 간 상호작용 방식: 클론(clone)

String 의 힙 데이터까지 깊이 복사하고 싶을 땐 clone 이라는 공용 메소드를 사용할 수 있습니다.

다음은 clone 메소드의 사용 예제입니다:

	let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);`

이 코드의 실행 결과는 힙 데이터까지 복사됐을 때의 메모리 구조를 나타낸 Figure 4-3 과 정확히 일치합니다.

여러분은 이 코드에서 clone 호출을 보고, 이 지점에서 성능에 영향이 갈지도 모르는 코드가 실행될 것을 알 수 있습니다. 즉, clone 은 해당 위치에서 무언가 다른 일이 수행될 것을 알려주는 시각적 지시자이기도 합니다.

스택에만 저장된 데이터: 복사(copy)

아직 다루지 않은 부분이 남았습니다. 다음 코드는 앞서 Listing 4-2 에서 본 정수형을 이용하는 코드입니다 (정상적으로 작동합니다):

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

하지만 이 코드는 방금 우리가 배운 내용과 맞지 않는 것처럼 보이네요. clone 을 호출하지도 않았는데 x 는 계속해서 유효하며 y 로 이동되지도 않았습니다.

  • 이유는 정수형 등 컴파일 타임에 크기가 고정된 타입은 모두 스택에 저장되기 때문입니다.
  • 스택에 저장되니, 복사본을 빠르게 만들 수 있고, 따라서 굳이 y 를 생성하고 나면 x 를 무효화 할 필요가 없습니다.
  • 다시 말해 이런 경우엔 깊은 복사와 얕은 복사 간 차이가 없습니다.
  • 여기선 clone 을 호출해도 얕은 복사와 차이가 없으니 생략해도 상관없죠.

러스트에는 정수형 등 스택에 저장되는 타입에 달아 놓을 수 있는 Copy 트레잇이 있습니다 (트레잇은 10장에서 자세히 다룹니다).

만약 어떤 타입에 이 Copy 트레잇이 구현되어 있다면, 대입 연산 후에도 기존 변수를 사용할 수 있죠. 하지만 구현하려는 타입이나, 구현하려는 타입 중 일부분에 Drop 트레잇이 구현된 경우엔 Copy 트레잇을 어노테이션(annotation) 할 수 없습니다.

즉, 스코프 밖으로 벗어났을 때 특정 동작이 요구되는 타입에 Copy 어노테이션을 추가하면 컴파일 오류가 발생합니다. 여러분이 만든 타입에 Copy 어노테이션을 추가하는 방법은 부록 C의 “Derivable Traits” 을 참고 바랍니다.

그래서, Copy 가능한 타입은 뭘까요? 타입마다 문서를 뒤져 정보를 찾아보고 확신을 얻을 수도 있겠지만, 일반적으로 단순한 스칼라 값의 묶음은 Copy 가능하고, 할당이 필요하거나 리소스의 일종인 경우엔 불가능합니다.

Copy 가능한 타입 목록 중 일부를 보여드리겠습니다.

  • 모든 정수형 타입 (예: u32)
  • truefalse 값을 갖는 논리 자료형 bool
  • 모든 부동 소수점 타입 (예: f64)
  • 문자 타입 char
  • Copy 가능한 타입만으로 구성된 튜플 (예를 들어, (i32, i32) 는 Copy 가능하지만 (i32, String) 은 불가능합니다)

소유권과 함수

함수로 값을 전달하는 행위는 변수에 값을 대입하는 행위와 의미가 유사합니다.

함수에 변수를 전달하면 대입 연산과 마찬가지로 이동이나 복사가 일어나기 때문이죠.

  • 러스트는 takes_ownership 함수를 호출한 이후에 s 를 사용하려 할 경우, 컴파일 타입 오류를 발생시킵니다.
  • 이런 정적 검사들이 프로그래머의 여러 실수를 방지해주죠. 어느 지점에서 변수를 사용할 수 있고, 어느 지점에서 소유권 규칙이 여러분을 제재하는지 확인해보려면 main 함수에 sx 변수를 사용하는 코드를 여기저기 추가해보세요.

반환 값과 스코프

소유권은 값을 반환하는 과정에서도 이동합니다.

상황은 다양할지라도, 변수의 소유권 규칙은 언제나 동일합니다.

  • 어떤 값을 다른 변수에 대입하면 값이 이동하고, 힙에 데이터를 갖는 변수가 스코프를 벗어나면, 사전에 해당 데이터가 이동되어 다른 변수가 소유하고 있지 않은 이상 drop 에 의해 데이터가 제거됩니다.

그럼, 함수가 값을 사용할 수 있도록 하되 소유권은 가져가지 않도록 하고 싶다면 어떻게 해야 할까요?

일단 다음과 같이 튜플을 이용해 여러 값을 돌려받는 게 가능하긴 하다는 걸 알려드리겠습니다:

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) {
let length = s.len(); // len() returns the length of a String
(s, length)
}

당연하지만 이런 방식으로 매개변수의 소유권을 되돌려받는 것은 좋지 않습니다. 거추장스럽고, 작업량이 필요 이상으로 많아지죠. 다행히도, 러스트에는 이 대신 사용할 참조자(references) 라는 기능을 가지고 있습니다.


4.2 참조자와 Borrow

이번에는 값의 소유권을 넘기는 대신 개체의 참조자(reference) 를 넘겨주는 방법을 소개하도록 하겠습니다.

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

fn calculate_length(s: &String) -> usize {
s.len()
}

calculate_length 함수에 s1 대신 &s1 을 넘기고, 함수 정의에 String 대신 &String 을 사용했네요.

여기 사용된 앰퍼샌드(&) 기호가 바로 참조자 입니다. 참조자를 사용하면 여러분이 어떤 값의 소유권을 갖지 않고도 해당 값을 참조할 수 있죠. 어떤 원리인지 Figure 4-5 다이어그램으로 알아보겠습니다:

&String s 는 String s1 을 가리킵니다

Figure 4-5: &String s 는 String s1 을 가리킴

Note: & 를 이용한 참조의 반대는 역참조(dereferencing) 라 합니다. 역참조 기호는 * 이며, 8장 에서 몇 번 다뤄보고 15장에서 자세한 내용을 배울 예정입니다.

s1 에 & 를 붙인 &s1 구문은 s1 값을 참조하나, 해당 값을 소유하지 않는 참조자를 생성합니다. 함수 정의에서도 마찬가지로 & 를 사용하여 매개변수 s 가 참조자 타입임을 나타내고 있죠.

참조자는 소유권을 갖지 않으므로, 스코프를 벗어나도 값은 drop 되지 않습니다. 주석으로 보여드리겠습니다.

fn calculate_length(s: &String) -> usize { // s is a reference to a String
s.len()
}
// Here, s goes out of scope.
// But because it does not have ownership of what
// it refers to, nothing happens.`

변수 s 가 유효한 스코프는 여타 함수의 매개변수에 적용되는 스코프와 동일합니다. 하지만 참조자에는 스코프를 벗어났을 때 값이 drop 되지 않는다는 차이점이 있고, 따라서 참조자를 매개변수로 갖는 함수는 소유권을 되돌려주기 위해 값을 다시 반환할 필요도 없습니다.

또한, 이처럼 참조자를 매개변수로 사용하는 것을 borrow(빌림) 이라 합니다. 현실에서도 다른 사람이 소유하고 있는 뭔가를 빌리고, 용무가 끝나면 돌려주는 것처럼요.

변수가 기본적으로 불변성을 지니듯, 레퍼런스도 마찬가지로 참조하는 것을 수정할 수 없습니다.

가변 참조자 (Mutable Reference)

```rust
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}

s 를 mut 로 변경하고, 참조자 생성 코드를 &mut s 로 변경해 가변 참조자를 생성하게 만든 뒤, 함수에서 가변 참조자를 넘겨받도록 some_string: &mut String 으로 수정하는 겁니다.

다만, 가변 참조자에는 특정 스코프 내 어떤 데이터를 가리키는 가변 참조자를 딱 하나만 만들 수 있다는 제한이 있다는 걸 알아두세요.

이 제약으로 인해 가변 참조자는 남용이 불가능합니다. 대부분의 언어에선 원하는 대로 값을 변경할 수 있으니, 러스트 입문자가 익숙해지지 못해 고생하는 원인이기도 하죠.

하지만, 이 제약 덕분에 러스트에선 컴파일 타임에 데이터 레이스(data race) 를 방지할 수 있습니다. 데이터 레이스란 **다음 세 가지 상황이 겹칠 때 일어나는 특정한 레이스 조건(race condition)**입니다:

멀티쓰레드 관련(함수형 프로그래밍이 각광 받은 이유)

  • 둘 이상의 포인터가 동시에 같은 데이터에 접근
  • 포인터 중 하나 이상이 데이터에 쓰기 작업을 시행
  • 데이터 접근 동기화 매커니즘이 존재하지 않음

데이터 레이스는 정의되지 않은 동작을 일으키며, 런타임에 추적하려고 할 때 문제 진단 및 수정이 어렵습니다. 하지만 러스트에선 데이터 레이스가 발생할 가능성이 있는 코드는 아예 컴파일되지 않으니 걱정할 필요가 없죠.

가변 참조자는 불변 참조자가 존재하는 동안에도 생성할 수 없습니다. 불변 참조자를 사용할 때 가변 참조자로 인해 값이 중간에 변경되리라 예상하지 않으니까요. 반면 불변 참조자는 데이터를 읽기만 하니 외부에 영향을 주지 않아 여러 개를 만들 수 있습니다.

참조자는 정의된 지점부터 시작해, 해당 참조자가 마지막으로 사용된 부분까지 유효합니다.

댕글링 참조자(Dangling Reference)

댕글링 포인터(dangling pointer) 란, 어떤 메모리를 가리키는 포인터가 남아있는 상황에서 해당 메모리를 해제해버림으로써, 다른 개체가 할당받았을지도 모르는 메모리를 참조하게 된 포인터를 말합니다.

포인터가 있는 언어에선 자칫 잘못하면 이 댕글링 포인터를 만들기 쉽죠. 하지만 러스트에선 어떤 데이터의 참조자를 만들면, 해당 참조자가 스코프를 벗어나기 이전에 데이터가 먼저 스코프를 벗어나는지 컴파일러에서 확인하여 댕글링 참조자가 생성되지 않도록 보장합니다.

한번, 컴파일 타임 에러가 발생할 만한 댕글링 참조자를 만들어 봅시다:

fn main() {
let reference_to_nothing = dangle();
}

fn dangle() -> &String {
let s = String::from("hello");
&s
}
fn main() {
let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle returns a reference to a String
let s = String::from("hello"); // s is a new String
&s // we return a reference to the String, s
} // Here, s goes out of scope, and is dropped. Its memory goes away.
// Danger!

s 는 dangle 함수 내에서 생성됐기 때문에, 함수가 끝날 때 할당 해제됩니다.

하지만 코드에선 &s 를 반환하려 했고, 이는 유효하지 않은 String 을 가리키는 참조자를 반환하는 행위이기 때문에 오류가 발생합니다.

따라서, 이런 경우엔 String 을 직접 반환해야 합니다.

참조자 규칙

배운 내용을 정리해 봅시다:

  • 여러분은 단 하나의 가변 참조자만 갖거나, 여러 개의 불변 참조자를 가질 수 있습니다.
  • 참조자는 항상 유효해야 합니다.

다음으로 알아볼 것은 참조자의 또 다른 종류인 슬라이스(slice) 입니다.


4.3 슬라이스(Slice)


소유권을 갖지 않는 또 하나의 타입은 슬라이스(slice) 입니다. 이 타입은 컬렉션(collection) 을 통째로 참조하는 것이 아닌, 컬렉션의 연속된 일련의 요소를 참조하는 데 사용합니다.

한번 간단한 함수를 만들어 봅시다.

  • 문자열을 입력받아,
  • 해당 문자열의 첫 번째 단어를 반환하는 함수를요.
  • 공백문자를 찾을 수 없는 경우엔 문자열 전체가 하나의 단어라는 뜻이니 전체 문자열을 반환하도록 합시다.
  • first_word 함수는 소유권을 가질 필요가 없으니 &String 을 매개변수로 갖게 했습니다.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
  • String 을 하나하나 쪼개서 해당 요소가 공백 값인지 확인해야 하므로, as_bytes 메소드를 이용해 바이트 배열로 변환
  • 그 다음, 바이트 배열에 사용할 반복자(iterator)를 iter 메소드로 생성했습니다:
  • iter 메소드는 컬렉션의 각 요소를 반환하고, enumerate 메소드는 iter 의 결과 값을 각각 튜플로 감싸 반환한다는 것만 알아두도록 합시다.
  • 이때 반환하는 튜플은 첫 번째 요소가 인덱스, 두 번째 요소가 해당 요소의 참조자로 이루어져 있습니다.
  • enumerate 메소드가 반환한 튜플은 패턴을 이용해 해체하였습니다.
    • 따라서 for 루프 내에서 i 는 튜플 요소 중 인덱스에 대응하고, &item 은 바이트에 대응됩니다.
    • 이때 & 를 사용하는 이유는 우린 iter().enumerate() 에서 얻은 요소의 참조자가 필요하기 때문입니다.
  • for 반복문 내에선 바이트 리터럴 문법으로 공백 문자를 찾고, 찾으면 해당 위치를 반환합니다.
  • 찾지 못했을 땐 s.len() 으로 문자열의 길이를 반환합니다.
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word will get the value 5
s.clear(); // this empties the String, making it equal to ""
// word still has the value 5 here, but there's no more string that
// we could meaningfully use the value 5 with. word is now totally invalid!
}

Listing 4-8: first_word 함수의 결과를 저장했으나, 이후에 String 의 내용이 변경된 상황

이 코드는 문법적으로 전혀 문제없고, 정상적으로 컴파일됩니다. s.clear() 을 호출한 후에 word 를 사용하는 코드를 작성하더라도, word 는 s 와 분리되어 있으니 결과는 마찬가지죠. 하지만 word 에 담긴 값 5 를 본래 목적대로 s 에서 첫 단어를 추출하는 데 사용할 경우, 버그를 유발할 수도 있습니다. s 의 내용물은 변경되었으니까요.

문자열 슬라이스

문자열 슬라이스(String Slice)는 String 의 일부를 가리키는 참조자를 말합니다:

	let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];

만드는 방식은 String 참조자와 유사하지만, [0..5] 가 추가로 붙어 있네요. 이는 String 전체가 아닌 일부만 가리킨다는 것을 의미합니다.

  • [starting_index..ending_index] 는 starting_index 부터 시작해 ending_index 직전, 즉 ending_index 에서 1을 뺀 위치까지 슬라이스를 생성한다는 의미입니다.
  • 슬라이스는 내부적으로 시작 위치, 길이를 데이터 구조에 저장하며, 길이 값은 ending_index 값에서 starting_index 값을 빼서 계산합니다.
  • 따라서 let world = &s[6..11]; 의 world 는 시작 위치로 s 의 (1부터 시작하여) 7번째 바이트를 가리키는 포인터와, 길이 값 5를 갖는 슬라이스가 되겠죠.

world 는 String s 의 7번째 바이트를 가리키는 포인터와, 길이 값 5 를 갖습니다

.. 범위 표현법은 맨 첫 번째 인덱스부터 시작하는 경우, 앞의 값을 생략할 수 있습니다. 즉 다음 코드에 등장하는 두 슬라이스 표현은 동일합니다:

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

let slice = &s[0..2];
let slice = &s[..2];

마찬가지로, String 맨 마지막 바이트까지 포함하는 슬라이스는 뒤의 값을 생략할 수 있습니다. 다음 코드에 등장하는 두 슬라이스 표현은 동일합니다:

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

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];

앞뒤 모두 생략할 경우, 전체 문자열이 슬라이스로 생성됩니다. 다음 코드에 등장하는 두 슬라이스 표현은 동일합니다:

	let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];

Note: 본 내용은 문자열 슬라이스를 소개할 목적이기에 ASCII 문자만 사용하여 문제가 발생하지 않았지만, 문자열 슬라이스 생성 시 인덱스는 절대 UTF-8 문자 바이트 중간에 지정해선 안 됩니다. 멀티바이트 문자 중간에 생성할 경우 프로그램은 오류가 발생하고 강제 종료됩니다. UTF-8 문자를 다루는 방법은 8장 “Storing UTF-8 Encoded Text with Strings” 절에서 자세히 알아볼 예정입니다.

여태 다룬 내용을 잘 기억해두고, first_word 함수가 슬라이스를 반환하도록 재작성해보죠. 문자열 슬라이스를 나타내는 타입은 &str 로 작성합니다.

fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}

이제 first_word 가 반환하는 값은 원래 데이터와 분리된 값이 아닙니다. 원래 데이터에서 슬라이스 시작 위치를 가리키는 참조자와, 슬라이스 요소 개수로 구성된 값이죠.

이전 절에서 배운 borrow 규칙 중, 특정 대상의 불변 참조자가 이미 존재할 경우에는 가변 참조자를 만들 수 없다는 규칙이 있었죠. clear 함수는 String 의 길이를 변경해야 하니 가변 참조자가 필요하지만 이미 불변 참조자가 존재하므로 오류가 발생하게 됩니다

그 외 슬라이스

문자열 슬라이스는 문자열에만 특정되어 있습니다. 물론 문자열에만 사용할 수 있는 것이 아닌 더 범용적인 슬라이스 타입도 존재합니다:

let a = [1, 2, 3, 4, 5];

문자열 일부를 참조할 때처럼 배열 일부를 참조할 수 있죠:

let a = [1, 2, 3, 4, 5]; let slice = &a[1..3];

이 슬라이스는 &[i32] 타입입니다. 동작 방식은 문자열 슬라이스와 동일합니다. 슬라이스의 첫 번째 요소를 참조하는 참조자와 슬라이스의 길이를 저장하여 동작하죠. 이런 슬라이스는 모든 컬렉션에 사용 가능합니다(컬렉션은 8장에서 자세히 알아볼 예정입니다).

요약

  • 러스트는 소유권, borrow, 슬라이스는 러스트가 컴파일 타임에 메모리 안정성을 보장하는 비결입니다.
  • 여타 시스템 프로그래밍 언어처럼 프로그래머에게 메모리 사용 제어 권한을 주면서, 어떠한 데이터의 소유자가 스코프를 벗어날 경우 자동으로 해당 데이터를 정리하는 것이 가능하죠.
  • 또한 제어 코드를 추가 작성하고 디버깅할 필요가 사라지니 프로그래머의 일이 줄어드는 결과도 가져옵니다.

소유권은 수많은 러스트 요소들의 동작 방법에 영향을 미치는 개념인 만큼 이후 내용에서도 계속해서 다룰 예정입니다. 그럼 이제 5장에서 struct 로 여러 데이터를 묶는 방법을 알아보죠.