Intro
지난 게시글에서 클라이언트의 요청 진행 과정과 서버의 응답이 네트워크를 통해 어떻게 클라이언트로 도달하는지에 대해 알아봤다.
그러면 다음 주제는 자연스럽게 서버에서 전달 받은 HTML 문서
를 브라우저가 어떻게 그려내는지(rendering
)일 것이다.
하지만 그전에, 클라이언트에게 전달되는 콘텐츠(HTML
, CSS
, JS
)가 어떤 과정을 거쳐 현재의 모습으로 발전해 왔는지 그 역사를 먼저 알아보자.
월드 와이드 웹(World Wide Web)은 인터넷에 연결된 사용자들이 서로의 정보를 공유할 수 있는 공간이라고 한다.
브라우저는 url로 해당 하는 서버에서 네트워크를 통해 html문서, 필요한 파일들을 받아와 내 화면에서 보여주는 역활을 한다.
그리고 프론트엔드 개발자의 역활은 어떤 기술을 사용하든 결국 서버에서 제공해줄 html
,css
,js
를 작성하는 것이다.
우리가 작성한 파일이 사용자에게 가기까지의 긴 여정을 정리해보자.
예전에 블로그를 처음 만들고, 기본 프로젝트 상태에서 포스트만 작성했었다.
그러다 한번 리뉴얼 해볼까 하는 마음으로 확인해보니 docusaurus 버전도 2에서 3로 올라 갔더라.
그래도 algolia Doc Search 기능은 붙여 놨었는데, 이번에 전반적으로 리뉴얼 해볼 겸 다시 붙이는 과정을 정리해보려고 한다.
최근에 sora AI라는 동영상 생성 AI 영상을 보았다. 놀랍고 무섭더라... ChatGPT(GPT-3.5) 처음 쓸때도 놀라웠고, Stable Diffusion때도 놀라웠고 sora도 놀랐다. AI 관련으로 엔디비아 주식도 놀랍다. 놀라움의 연속이다.
대부분 개발자들(연관 사무직)은 AI가 자신을 대체하지 않을까라는 생각을 다들 한번은 해봤을 것 같다.
그만큼 AI의 발전속도는 놀랍다.
피할 수 없으면 즐기라했던가, 늦었다고 생각했을 때가 가장 빠른때라던가, AI 관련 프로덕트를 뭐라도 사용 해봐야 할 것만 같다. 그래도 ChatGPT는 자주 사용하니까 이번에 Stable Diffusion으로 이미지 생성을 해보기로 했다.
Stable Diffusion: 텍스트 및 이미지 프롬프트에서 고유한 실사 이미지를 생성하는 생성형 인공 지능(생성형 AI) 모델 web-ui: 웹 기반 유저 인터페이스
가장 대중적인 방법인 web-ui로 이미지 생성을 해보자!! 본인의 기기는 M1 max이다.
나는 친한 동생과 함께 2024/01/06 ~ 2024/01/20 일정으로 튀르키예 여행을 다녀왔다. 동서양의 교차점이자 어릴때부터 막연히 가고 싶었던 튀르키예 여행을 행복하게, 무사히 마무리 한 후 다음 여행자들을 위한 후기를 남긴다.
신생사이트는 일단 대중에게 노출되는 것이 무엇보다 중요하다. 이는 마케팅의 영역일 것이다.
이 점에 대해 프론트엔드 개발자로 기여 할 수 있는 부분은 SEO
관련 부분일 것이다.
검색엔진은 SEO가 잘 된 사이트에 더 높은 점수를 부여하고 사용자에게 더 잘 노출될 수 있게 한다.
SEO는 사실 복잡한 개념은 아니라고 생각한다.
이정도면 충분하다고 생각한다. 과거에는 백링크의 수가 검색 결과 순위를 결정하는 주요 요인이었지만, 최근에는 실제 트래픽이 더욱 중요해졌다.
프론트에서 할 수 있는 부분을 놓치지 말고 진행하자.
여러가지 상황이 있었다.
열심히 살고 있는 것 같은데, 왜 이렇게 할게 늘기만 하는지 ㅋㅋ... 효율성이 중요한 시점이다.
간만에 포스트를 쓰는 이유는 심란해서이다.
솔라나 프로그램을 작성할 목적으로, 러스트 언어를 두달 정도 학습했었다.
이제 프로그램 분석하고 작성해보는 단계를 진행하고 있는데, 갑자기 왠 FTX 벼락 ㅠㅠ 인지 솔라나가 망하게 생겼다...
Rust + 스마트 컨트랙트를 둘다 만족시켜주는 선택지는 이렇게 사라지고 말았다.
자산도 다 코인으로 바꿔놨는데 하 인생...
2022년 마지막까지 열심히 살고, KRW 채굴 역량이나 올려야겠다!
러스트의 신뢰성에 대한 약속은 에러 처리에도 확장되어 있습니다.
에러는 소프트웨어에서 피할 수 없는 현실이며, 따라서 러스트는 무언가 잘못되었을 경우에 대한 처리를 위한 몇 가지 기능을 갖추고 있습니다.
러스트는 에러를 두 가지 범주로 묶습니다:
대부분의 언어들은 이 두 종류의 에러를 분간하지 않으며 예외 처리(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)
란 어떤 지점에 도달하기까지 호출해온 모든 함수의 리스트를 말합니다.환경 변수 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
이후에는 읽거나 쓰기 위해 이 파일 핸들을 사용할 수 있습니다.
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를 가진, 표준 라이브러리에서 제공하는 열거형입니다.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
메소드 말이죠.File::open
함수를 호출하면서 시작합니다.match
와 유사한 식으로 match
을 이용해서 Result
값을 처리하는데, Err
경우에 panic!
을 호출하는 대신 이 함수를 일찍 끝내고 File::open
으로부터의 에러 값을 마치 이 함수의 에러 값인 것처럼 호출하는 쪽의 코드에게 전달합니다.File::open
이 성공하면, 파일 핸들을 f
에 저장하고 계속합니다.s
에 새로운 String
을 생성하고 파일의 콘텐츠를 읽어 s
에 넣기 위해 f
에 있는 파일 핸들의 read_to_string
메소드를 호출합니다.File::open
가 성공하더라도 read_to_string
메소드가 실패할 수 있기 때문에 이 함수 또한 Result
를 반환합니다.Result
를 처리하기 위해서 또 다른 match
가 필요합니다:read_to_string
이 성공하면, 우리의 함수가 성공한 것이고, 이제 s
안에 있는 파일로부터 읽어들인 사용자 이름을 Ok
에 싸서 반환합니다.read_to_string
이 실패하면, File::open
의 반환값을 처리했던 match
에서 에러값을 반환하는 것과 같은 방식으로 에러 값을 반환합니다.return
이라 말할 필요는 없는데, 그 이유는 이 함수의 마지막 표현식이기 때문입니다.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
를 여전히 반환합니다.?
는 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
를 처리하도록 할 것인데,
Result
를 처리할 필요가 분명히 있습니다.여러분의 코드가 결국 나쁜 상태에 처하게 될 가능성이 있을 때는 여러분의 코드에 panic!
을 넣는 것이 바람직합니다.
이 글에서 말하는 나쁜 상태란 어떤 가정, 보장, 계약, 혹은 불변성이 깨질 때를 뜻하는 것으로, 이를테면 유효하지 않은 값이나 모순되는 값, 혹은 찾을 수 없는 값이 여러분의 코드를 통과할 경우를 말합니다
만일 어떤 사람이 여러분의 코드를 호출하고 타당하지 않은 값을 집어넣었다면, panic!
을 써서 여러분의 라이브러리를 사용하고 있는 사람에게 그들의 코드 내의 버그를 알려서 개발하는 동안 이를 고칠 수 있게끔 하는 것이 최선책일 수도 있습니다.
비슷한 식으로, 만일 여러분의 제어권을 벗어난 외부 코드를 호출하고 있고, 이것이 여러분이 고칠 방법이 없는 유효하지 않은 상태를 반환한다면, panic!
이 종종 적합합니다.
나쁜 상태에 도달했지만, 여러분이 얼마나 코드를 잘 작성했든 간에 일어날 것으로 예상될 때라면 panic!
을 호출하는 것보다 Result
를 반환하는 것이 여전히 더 적합합니다.
이에 대한 예는 기형적인 데이터가 주어지는 파서나, 속도 제한에 달했음을 나타내는 상태를 반환하는 HTTP 요청 등을 포함합니다.
이러한 경우, 여러분은 이러한 나쁜 상태를 위로 전파하기 위해 호출자가 그 문제를 어떻게 처리할지를 결정할 수 있도록 하기 위해서 Result
를 반환하는 방식으로 실패가 예상 가능한 것임을 알려줘야 합니다. panic!
에 빠지는 것은 이러한 경우를 처리하는 최선의 방식이 아닐 것입니다.
panic!
을 호출해야 합니다.panic!
을 호출하는 주된 이유입니다:
하지만 여러분의 모든 함수 내에서 수많은 에러 검사를 한다는 것은 장황하고 짜증 날 것입니다. 다행스럽게도, 러스트의 타입 시스템이 (그리고 컴파일러가 하는 타입 검사 기능이) 여러분을 위해 수많은 검사를 해줄 수 있습니다. 여러분의 함수가 특정한 타입을 파라미터로 갖고 있다면, 여러분이 유효한 값을 갖는다는 것을 컴파일러가 이미 보장했음을 아는 상태로 여러분의 코드 로직을 진행할 수 있습니다.
Option
이 아닌 어떤 타입을 갖고 있다면,Some
과 None
variant에 대한 두 경우를 처리하지 않아도 됩니다:u32
와 같은 부호 없는 정수를 이용하는 것이 있는데, 이는 파라미터가 절대 음수가 아님을 보장합니다.러스트의 타입 시스템을 이용하여 유효한 값을 보장하는 아이디어에서 한 발 더 나가서, 유효성을 위한 커스텀 타입을 생성하는 것을 살펴봅시다.
if
표현식은 우리의 값이 범위 밖에 있는지 혹은 그렇지 않은지 검사하고, 사용자에게 문제점을 말해주고, continue
를 호출하여 루프의 다음 반복을 시작하고 다른 추측값을 요청해줍니다. if
표현식 이후에는, guess
가 1과 100 사이의 값이라는 것을 아는 상태에서 guess
와 비밀 숫자의 비교를 진행할 수 있습니다.
대신, 우리는 새로운 타입을 만들어서, 유효성 확인을 모든 곳에서 반복하는 것보다는 차라리 그 타입의 인스턴스를 생성하는 함수 내에 유효성 확인을 넣을 수 있습니다.
이 방식에서, 함수가 그 시그니처 내에서 새로운 타입을 이용하고 받은 값을 자신 있게 사용하는 것은 안전합니다. 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
타입
u32
를 갖는 value
라는 이름의 항목을 가진 Guess
라는 이름의 구조체를 선언하였습니다.Guess
값의 인스턴스를 생성하는 new
라는 이름의 연관 함수를 구현하였습니다.
new
함수는 u32
타입의 값인 value
를 파라미터를 갖고 Guess
를 반환하도록 정의 되었습니다.new
함수의 본체에 있는 코드는 value
가 1부터 100 사이의 값인지 확인하는 테스트를 합니다.value
가 이 테스트에 통과하지 못하면 panic!
을 호출하며, 이는 이 코드를 호출하는 프로그래머에게 고쳐야 할 버그가 있음을 알려주는데, 범위 밖의 value
를 가지고 Guess
를 생성하는 것은 Guess::new
가 필요로 하는 계약을 위반하기 때문입니다.Guess::new
가 패닉을 일으킬 수도 있는 조건은 공개된 API 문서 내에 다뤄져야 합니다;panic!
의 가능성을 가리키는 것에 대한 문서 관례는 14장에서 다룰 것입니다.value
가 테스트를 통과한다면, value
항목을 value
파라미터로 설정한 새로운 Guess
를 생성하여 이 Guess
를 반환합니다.self
를 빌리고, 파라미터를 갖지 않으며, u32
를 반환하는 value
라는 이름의 메소드를 구현했습니다.
Guess
구조체의 value
항목이 비공개이기 때문에 필요합니다.value
항목이 비공개라서 Guess
구조체를 이용하는 코드가 value
를 직접 설정하지 못하도록 하는 것은 중요합니다:Guess::new
함수를 이용하여 새로운 Guess
의 인스턴스를 만들어야 하는데,Guess
가 Guess::new
함수의 조건들을 확인한 적이 없는 value
를 갖는 방법이 없음을 보장합니다.그러면 파라미터를 가지고 있거나 오직 1에서 100 사이의 숫자를 반환하는 함수는 u32
보다는 Guess
를 얻거나 반환하는 시그니처로 선언되고 더 이상의 확인이 필요치 않을 것입니다.
panic!
매크로는 여러분의 프로그램이 처리 불가능한 상태에 놓여 있음에 대한 신호를 주고 여러분이 유효하지 않거나 잘못된 값으로 계속 진행하는 시도를 하는 대신 실행을 멈추게끔 해줍니다.Result
열거형은 러스트의 타입 시스템을 이용하여 여러분의 코드가 복구할 수 있는 방법으로 연산이 실패할 수도 있음을 알려줍니다.Result
를 이용하면 여러분의 코드를 호출하는 코드에게 잠재적인 성공이나 실패를 처리해야 할 필요가 있음을 알려줄 수 있습니다.panic!
과 Result
를 적합한 상황에서 사용하는 것은 여러분의 코드가 불가피한 문제에 직면했을 때도 더 신뢰할 수 있도록 해줄 것입니다.이제 표준 라이브러리가 Option
과 Result
열거형을 가지고 제네릭을 사용하는 유용한 방식들을 보았으니, 제네릭이 어떤 식으로 동작하고 여러분의 코드에 어떻게 이용할 수 있는지에 대해 다음 장에서 이야기해 보겠습니다.
러스트의 표준 라이브러리에는 컬렉션
이라 불리는 여러 개의 매우 유용한 데이터 구조들이 포함되어 있습니다.
대부분의 다른 데이터 타입들은 하나의 특정한 값을 나타내지만, 컬렉션은 다수의 값을 담을 수 있습니다.
내장된 배열(build-in array)와 튜플 타입과는 달리, 이 컬렉션들이 가리키고 있는 데이터들은 힙에 저장되는데, 이는 즉 데이터량이 컴파일 타임에 결정되지 않아도 되며 프로그램이 실행될 때 늘어나거나 줄어들 수 있다는 의미입니다.
각각의 컬렉션 종류는 서로 다른 용량과 비용을 가지고 있으며, 여러분의 현재 상황에 따라 적절한 컬렉션을 선택하는 것은 시간이 지남에 따라 발전시켜야 할 기술입니다.
이번 장에서는 러스트 프로그램에서 굉장히 자주 사용되는 세 가지 컬렉션에 대해 논의해 보겠습니다:
String
타입은 이전에 다루었지만, 이번 장에서는 더 깊이 있게 이야기해 보겠습니다.표준 라이브러리가 제공해주는 다른 종류의 컬렉션에 대해 알고 싶으시면, the documentation를 봐 주세요.
우리가 보게될 첫번째 콜렉션은 벡터
라고도 알려진 Vec<T>
입니다.
비어있는 새 벡터를 만들기 위해서는, 아래의 Listing 8-1과 같이 Vec::new
함수를 호출해 줍니다:
let v: Vec<i32> = Vec::new();
Vec
타입은 어떠한 종류의 값이라도 저장할 수 있다는 것,(<>)
안에 적는다는 것만 알아두세요.Vec<T>
을 생성하는 것이 더 일반적이며, 러스트는 편의를 위해 vec!
매크로를 제공합니다.Vec
을 생성합니다.1
, 2
, 3
을 저장하고 있는 새로운 Vec<i32>
을 생성할 것입니다:let v = vec![1, 2, 3];
Listing 8-2: 값을 저장하고 있는 새로운 벡터 생성하기
초기 i32
값들을 제공했기 때문에, 러스트는 v
가 Vec
타입이라는 것을 유추할 수 있으며, 그래서 타입 명시는 필요치 않습니다.
벡터를 만들고 여기에 요소들을 추가하기 위해서 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
를 사용하면 세번째 값이 얻어집니다:
&
와 []
를 이용하여 참조자를 얻은 것과, 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 인덱스에 있는 요소에 접근하기
이 프로그램을 실행하면, 첫번째의 []
메소드는 panic!
을 일으키는데, 이는 존재하지 않는 요소를 참조하기 때문입니다.
이 방법은 여러분의 프로그램이 벡터의 끝을 넘어서는 요소에 접근하는 시도를 하면 프로그램이 죽게끔 하는 치명적 에러를 발생하도록 하기를 고려하는 경우 가장 좋습니다.
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
으로 넘어갑시다!
새로운 러스트인들은 흔히들 스트링 부분에서 막히는데 이는 세 가지 개념의 조합으로 인한 것입니다:
스트링이 컬렉션 장에 있는 이유는 스트링이 바이트의 컬렉션 및 이 바이트들을 텍스트로 통역할때 유용한 기능을 제공하는 몇몇 메소드로 구현되어 있기 때문입니다.
이번 절에서는 생성, 갱신, 값 읽기와 같은 모든 컬렉션 타입이 가지고 있는, String
에서의 연산에 대해 이야기 해보겠습니다.
또한 String
을 다른 컬렉션들과 다르게 만드는 부분, 즉 사람과 컴퓨터가 String
데이터를 통역하는 방식 간의 차이로 인해 생기는 String
인덱싱의 복잡함을 논의해보겠습니다.
러스트는 핵심 언어 기능 내에서 딱 한가지 스트링 타입만 제공하는데, 이는 바로 스트링 슬라이스
인 str
이고, 이것의 참조자 형태인 &str
을 많이 봤죠.
String
타입은 핵심 언어 기능 내에 구현된 것이 아니고 러스트의 표준 라이브러리
를 통해 제공되며, 커질 수 있고, 가변적이며, 소유권을 갖고 있고, UTF-8로 인코딩된 스트링 타입입니다.
러스트인들이 스트링
에 대해 이야기할 때, 그들은 보통 String
과 스트링 슬라이스 &str
타입 둘 모두를 이야기한 것이지, 이들 중 하나를 뜻한 것은 아닙니다.
이번 절은 대부분 String
에 관한 것이지만, 두 타입 모두 러스트 표준 라이브러리에서 매우 많이 사용되며 String
과 스트링 슬라이스 모두 UTF-8로 인코딩되어 있습니다.
또한 러스트 표준 라이브러리는 OsString
, OsStr
, CString
, 그리고 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[..]
로 바뀌는 것으로 생각할 수 있도록 해줍니다.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가 되는데,Vec
이 4바이트 길이라는 뜻입니다.let len = String::from("Здравствуйте").len();
이를 보여주기 위해, 다음과 같은 유효하지 않은 러스트 코드를 고려해 보세요:
let hello = "Здравствуйте";
let answer = &hello[0];
answer
의 값은 무엇이 되어야 할까요?З
이 되어야 할까요?
З
의 첫번째 바이트는 208
이고, 두번째는 151
이므로,answer
는 사실 208
이 되어야 하지만, 208
은 그 자체로는 유효한 문자가 아닙니다.208
을 반환하는 것은 사람들이 이 스트링의 첫번째 글자를 요청했을 경우 사람들이 기대하는 것이 아닙니다;&"hello"[0]
는 h
가 아니라 104
를 반환합니다.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 데이터를 처리하는데 있어 더 많은 생각을 해야한다는 의미입니다.이것보다 살짝 덜 복잡한 것으로 옮겨 갑시다: 해쉬맵이요!
마지막으로 볼 일반적인 컬렉션은 해쉬맵
입니다.
HashMap<K, V>
타입은 K
타입의 키에 V
타입의 값을 매핑한 것을 저장합니다.해쉬
, 맵
, 오브젝트
, 해쉬 테이블
, 혹은 연관 배열(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
로 가져와야할 필요가 있음을 주목하세요.HashMap
은 String
타입의 키와 i32
타입의 값을 갖습니다.collect
메소드를 사용하는 것인데, 이 벡터의 각 튜플은 키와 키에 대한 값으로 구성되어 있습니다.collect
메소드는 데이터를 모아서 HashMap
을 포함한 여러 컬렉션 타입으로 만들어줍니다.
zip
메소드를 이용하여 “Blue”와 10이 한 쌍이 되는 식으로 튜플의 벡터를 생성할 수 있습니다.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
를 해쉬맵으로 이동시킨 후에는 더 이상 이 둘을 사용할 수 없습니다.
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
을 반환합니다.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
인데, 해당 키가 있는지 혹은 없는지를 나타냅니다.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)에 저항 기능을 제공할 수 있는 암호학적으로 보안되는 해쉬 함수를 사용합니다.BuildHasher
트레잇을 구현한 타입을 말합니다.
벡터, 스트링, 그리고 해쉬맵은 프로그램 내에서 여러분이 데이터를 저장하고, 접근하고, 수정하고 싶어하는 곳마다 필요한 수많은 기능들을 제공해줄 것입니다.
이제 여러분이 풀 준비가 되어있어야 할만한 몇가지 연습문제를 소개합니다:
표준 라이브러리 API 문서는 이 연습문제들에 대해 도움이 될만한 벡터, 스트링, 그리고 해쉬맵의 메소드들을 설명해줍니다!
우리는 연산이 실패할 수 있는 더 복잡한 프로그램으로 진입하고 있는 상황입니다; 따라서, 다음은 에러 처리에 대해 다룰 완벽한 시간이란 뜻이죠!
거대한 프로그램을 작성할 때에는 코드 관리가 무척 중요합니다.
한 패키지 내에는 여러 바이너리 크레이트와 (원할 경우) 라이브러리 크레이트를 포함시킬 수 있으므로, 커진 프로젝트의 각 부분을 크레이트로 나눠서 외부 라이브러리처럼 쓸 수 있습니다.
이번 장에서 배워 볼 것은 이러한 기술들입니다. 상호연관된 패키지들로 이루어진 대규모 프로젝트의 경우, 14장 “Cargo Workspaces” 절에서 다룰 예정인, Cargo에서 제공하는 Workspace 기능을 이용합니다.
그룹화 외에도, 세부 구현을 캡슐화하면 더 고수준으로 코드를 재사용할 수 있습니다. 어떤 작업을 구현해두고 다른 코드에서 해당 코드의 공개 인터페이스를 통해 호출하면, 세부적인 작동은 알 필요 없죠. 여러분은 어떤 부분을 다른 코드에서 사용할 수 있도록 공개하고, 어떤 부분을 비공개된 세부 구현으로 만들어 자유롭게 수정할 수 있도록 할지를 정의하는 방식으로 코드를 작성합니다. 머릿속에 담아두어야 하는 정보의 양을 줄이는 또 다른 방법이기도 합니다.
스코프 개념도 관련되어 있습니다. 중첩된 컨텍스트에 작성한 코드는 "스코프 내" 정의된 다양한 이름들이 사용됩니다. 프로그래머나 컴파일러가 코드를 읽고, 쓰고, 컴파일할 때는 어떤 위치의 어떤 이름이 무엇을 의미하는지 알아야 합니다. 해당 이름이 변수인지, 함수인지, 열거형인지, 모듈인지, 상수인지, 그 외 요소인지 말이죠. 동일한 스코프 내에는 같은 이름을 가진 요소가 둘 이상 존재할 수 없기 때문에, 스코프를 의도적으로 생성해 어떤 이름은 스코프 내에 위치하고 어떤 이름은 스코프 밖에 위치하도록 조정하기도 합니다. (이름 충돌을 해결하는 도구도 존재합니다)
러스트에는 코드 조직화에 필요한 기능이 여럿 있습니다. 어떤 세부 정보를 외부에 노출할지, 비공개로 둘지, 프로그램의 스코프 내 항목 이름 등 다양합니다. 이를 통틀어 모듈 시스템 이라 하며, 다음 기능들이 포함됩니다.
이번 장에서는 이 기능들을 모두 다뤄보면서 어떻게 작동하고, 어떻게 사용해서 스코프를 관리하는지 등을 배워보겠습니다. 이번 장을 마치고 나면, 모듈 시스템을 확실히 이해하고 스코프를 자유자재로 다룰 수 있을 거랍니다!
모듈 시스템
에서 처음 다뤄볼 내용은 패키지
와 크레이트
입니다.
패키지에 무엇을 포함할 수 있는가에 대해서는 규칙이 몇 가지 있습니다.
패키지를 생성할 때 어떤 일이 일어나는지 살펴보죠. 먼저 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/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
로 접근해야 하죠.
이번에는 모듈, 항목의 이름을 지정하는 경로(path), 스코프에 경로를 가져오는 use
키워드, 항목을 공개하는 데 사용하는 pub
키워드를 알아보겠습니다. as
키워드, 외부 패키지, 글롭 연산자 등도 다룰 예정이지만, 일단은 모듈에 집중하죠!
예시로, 레스토랑 기능을 제공하는 라이브러리 크레이트를 작성한다고 가정해보죠. 코드 구조에 집중할 수 있도록 레스토랑을 실제 코드로 구현하지는 않고, 본문은 비워둔 함수 시그니처만 정의하겠습니다.
레스토랑 업계에서는 레스토랑을 크게 접객 부서(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
)을 명시하고, 본문을 중괄호로 감싸 모듈을 정의하였습니다. hosting
, serving
모듈처럼, 모듈 내에는 다른 모듈을 넣을 수 있습니다. 모듈은 구조체, 열거형, 상수, 트레잇, 함수(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
내에 위치함)hosting
, serving
모듈이 둘 다 동일하게 front_of_house
모듈 내에 위치한 것처럼, 어떤 모듈이 형제 관계에 있는지 나타내기도 합니다.crate
라는 모듈이 암묵적으로 위치한다는 점을 기억해두세요.모듈 트리에서 컴퓨터 파일 시스템의 디렉토리 트리를 연상하셨다면, 적절한 비유입니다! 파일 시스템의 디렉토리처럼, 여러분은 모듈로 코드를 조직화합니다.
러스트 모듈 조직도에서 항목을 찾는 방법은, 파일 시스템에서 경로를 사용하는 방법과 동일합니다.
경로는 두 가지 형태가 존재합니다.
crate
리터럴을 사용하며, 크레이트 루트를 기준점으로 사용합니다.self
, super
를 사용하며, 현재 모듈을 기준점으로 사용합니다.절대 경로, 상대 경로 뒤에는 ::
으로 구분된 식별자가 하나 이상 따라옵니다.
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) 를 정의하는 역할도 있습니다.
캡슐화
된 세부 구현은 외부 코드에서 호출하거나 의존할 수 없고, 알 수도 없습니다.
따라서 비공개로 만들고자 하는 함수나 구조체가 있다면, 모듈 내에 위치시키면 됩니다.
러스트에서, 모든 항목(함수, 메소드, 구조체, 열거형, 모듈, 상수)은 기본적으로 비공개입니다.
부모 모듈 내 항목은 자식 모듈 내 비공개 항목을 사용할 수 없지만, 자식 모듈 내 항목은 부모 모듈 내 항목을 사용할 수 있습니다.
이유는, 자식 모듈의 세부 구현은 감싸져서 숨겨져 있지만, 자식 모듈 내에서는 자신이 정의된 컨텍스트를 볼 수 있기 때문입니다.
러스트 모듈 시스템은 내부의 세부 구현을 기본적으로 숨기도록 되어 있습니다.
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
함수가 정의되어 있는 모듈에서 시작하는 상대 경로를 사용할 수 있습니다.
이후 hosting
, add_to_waitlist
은 pub
으로 지정되어 있으므로 나머지 경로도 문제 없습니다.
따라서 이 함수 호출도 유효합니다!
super
로 시작하는 상대 경로super
로 시작하는 상대 경로는 부모 모듈을 기준점으로 사용합니다. 이는 파일시스템 경로에서 ..
로 시작하는 것과 동일합니다.
부모 모듈을 기준으로 삼아야 하는 상황은 언제일까요?
back_of_house
모듈과 serve_order
함수는 크레이트 모듈 구조 변경 시 서로의 관계를 유지한 채 함께 이동될 가능성이 높습니다. 그러므로 super
를 사용하면, 추후에 다른 모듈에 이동시키더라도 수정해야 할 코드를 줄일 수 있습니다
pub
키워드로 구조체와 열거형을 공개할 수도 있습니다.pub
를 사용하면 구조체는 공개되지만, 구조체 내 필드는 비공개로 유지됩니다.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
함수에서 Soup
, Salad
variant를 사용할 수 있습니다.pub
키워드를 작성하는 것도 귀찮은 일이기 때문입니다.남은 pub
키워드 관련 내용은 모듈 시스템의 마지막 기능인 use
키워드입니다. 먼저 use
키워드 단독 사용법을 다루고, 그다음 use
와 pub
을 연계하여 사용하는 방법을 다루겠습니다.
use
키워드로 경로를 스코프 내로 가져오기add_to_waitlist
호출할 때마다 front_of_house
, hosting
모듈을 매번 명시해 주어야 하죠.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
키워드를 추가해 정의를 공개할 수 있습니다.
다음 장에서는 여러분의 깔끔하게 구성된 코드에서 사용할 수 있는 표준 라이브러리의 컬렉션 자료구조를 몇 가지 배워보겠습니다.