들어가기 앞서

항상 그렇게 명확지는 않았지만, 러스트 프로그래밍 언어는 근본적으로 권한 분산에 관한 것입니다: 여러분이 어떠한 종류의 코드를 작성하는 중이던 간에, 러스트는 여러분에게 더 멀리 뻗어갈 권한을 주어, 다양한 분야에서 여러분이 전에 했던 것보다 자신감을 가지고 프로그래밍 할 수 있도록 해줍니다.

예를 들어, 메모리 관리, 데이터 표현, 그리고 동시성에 대한 저수준의 디테일을 다루는 “시스템 레벨”의 일을 해보세요. 전통적으로, 이 프로그래밍 영역은 신비로운 것으로 보이고, 이 영역의 악명높은 함정에 빠지지 않기 위해 필요한 수 년의 시간을 배우는데 헌신한 몇몇의 선택받은 자들만이 접근할 수 있는 것으로 여겨졌습니다. 그리고 이 분야를 연마해온 그들조차도 그들의 코드가 이용당하거나, 망가지거나, 붕괴되지 않도록 조심스럽게 작업을 합니다.

러스트는 여러분이 길을 잃지 않도록 하기 위해, 오래된 함정들을 제거하고 친근하면서도 세련된 도구 세트를 제공함으로써 이 장벽들을 부숩니다. 저수준의 제어에 “살짝만 발을 담글” 필요가 있는 프로그래머들은, 변덕스러운 툴체인의 미세한 지점들을 학습할 필요없이 러스트를 통해 그렇게 할 수 있습니다. 그 정도가 아니라, 이 언어는 속도와 메모리 사용 측면에서 효율적이면서도 안정적인 코드를 작성해 나갈 수 있도록 여러분들을 자연스럽게 안내하도록 설계되었습니다.

이미 저수준의 코드를 가지고 일하고 있는 프로그래머들은 러스트를 사용하여 그들의 야망을 더 키울 수 있습니다. 예를 들면, 러스트에서 소개하는 병렬성은 상대적으로 저위험성 연산입니다: 컴파일러가 여러분을 위해 고전적인 실수를 잡아줄 것입니다. 그리고 여러분은 뜻하지 않게 프로그램이 망가지거나 악용되지 않으리라는 자신감을 가지고 여러분의 코드에 대하여 더 공격적인 최적화에 몰두할 수 있습니다.

하지만 러스트는 저수준의 시스템 프로그래밍에 한정되지 않습니다. 이 언어는 CLI 앱, 웹 서버, 그리고 작성하기에 꽤나 즐거운 종류의 다른 코드들을 만들기에 충분할 정도로 표현력이 풍부하고 인간 공학적입니다 - 여러분은 이 책의 뒷 부분에서 이에 대한 단순한 예제들을 보게될 것입니다. 러스트로 일하는 것은 여러분이 어떤 영역에서 또다른 영역으로 옮기는 기술을 만들수 있게 해줍니다; 여러분은 웹 앱을 작성하는 것으로 러스트를 배울 수 있고, 그 다음 여러분의 라즈베리 파이를 대상으로 동일한 기술을 적용할 수 있습니다.

이 책은 러스트 사용자에게 권한을 주기 위해 러스트의 잠재력을 모두 담아내었습니다. 이 책은 러스트에 대한 여러분의 지식을 향상시키는 것 뿐만 아니라, 일반적인 프로그래머로서의 도약과 자신감을 향상시키는 것을 돕기 위한 의도로 친절하고 이해하기 쉬운 텍스트로 되어있습니다. 그러니 뛰어 들어서 배울 준비를 하세요-러스트 커뮤니티에 오신 것을 환영합니다!

  • Nicholas Matsakis와 Aaron Turon

소개

러스트 프로그래밍 언어, 러스트 입문서에 오신 것을 환영합니다.

러스트 프로그래밍 언어는 여러분이 더 빠르고, 더 안정적인 소프트웨어를 작성하도록 해줍니다. 프로그래밍 언어 디자인에서 고수준의 인간공학과 저수준의 제어는 종종 조화롭지 못합니다; 러스트는 이러한 갈등에 도전합니다. 강력한 기술적 능력과 훌륭한 개발자 경험을 조화롭게 하는 것을 통해, 러스트는 (메모리 사용 같은) 저수준 디테일을 그러한 제어를 하는데 동반되는 전통적으로 귀찮은 것들 없이도 제어하는 옵션을 제공합니다.

러스트는 누구를 위한 것인가요?

러스트는 다양한 이유로 수많은 사람들에게 이상적입니다. 가장 중요한 그룹 중 일부를 살펴봅시다.

개발자 팀

러스트는 시스템 프로그래밍 지식에 대한 다양한 수준을 가진 큰 개발자 팀들 사이에서 협업을 하기 위한 생산적인 도구라는 것이 밝혀지고 있습니다. 저수준 코드는 다양한 감지하기 힘든 버그들에 노출되기 쉬운데, 이는 다른 대부분의 언어들에서는 경험 있는 개발자들에 의한 대규모의 테스트 및 세심한 코드 리뷰를 통해 잡을 수 있습니다. 러스트에서는, 컴파일러가 동시성 버그를 포함하여 이러한 찾기 어려운 버그를 가진 코드의 컴파일을 거부함으로써 문지기 역할을 수행합니다. 이 컴파일러와 나란히 작업을 함으로써, 팀은 버그를 추적하는 것보다는 프로그램의 로직에 집중하는데 더 많은 시간을 쓸 수 있습니다.

또한 러스트는 시스템 프로그램 세계로 현대적인 개발자 도구들을 가져옵니다:

  • Cargo라고 불리는 기본 구성에 포함된 의존성(dependency) 관리자 및 빌드 도구는, 러스트 생태계 상에서 고통 없고 일관되게 의존성을 추가하고, 컴파일하고, 관리하도록 해줍니다.
  • Rustfmt는 개발자들 사이에서 일관된 코딩 스타일을 반드시 따르도록 해줍니다.
  • 러스트 언어 서버(Rust Language Server)는 코드 자동완성(code completion) 및 인라인 에러 메시지를 위한 통합 개발 환경(IDE)으로의 결합에 힘을 제공합니다.

이들 및 러스트 생태계의 다른 툴들을 이용함으로서, 개발자들은 시스템 수준의 코드를 작성하면서도 생산적일 수 있습니다.

학생

러스트는 학생들 및 시스템 개념에 대하여 공부하는데 관심이 있는 이들을 위한 것입니다. 러스트를 사용하여, 많은 사람들이 운영 체제 개발과 같은 주제에 대해 공부해왔습니다. 커뮤니티는 매우 따뜻하고 기쁘게 학생들을 질문에 대하여 대답해줍니다. 이 책과 같은 노력들을 통해서, 러스트 팀은 더 많은 사람들, 특히 프로그래밍에 새로 입문한 사람들이 시스템 개념에 더 접근하기 쉬워지길 원합니다.

회사

크고 작은 수백 개의 회사들이 다양한 작업들을 위해 프로덕션에 러스트를 사용합니다. 그 작업들에는 커맨드 라인 도구, 웹 서비스, 데브옵스(DevOps) 도구화, 임베디드 장치, 오디오 및 비디오 분석과 트랜스코딩, 암호화폐, 생물정보학, 검색 엔진, IOT(internet of things) 애플리케이션, 머신 러닝, 그리고 심지어는 파이어폭스 웹브라우저의 주요 부분들을 포함합니다.

오픈 소스 개발자

러스트는 러스트 프로그래밍 언어, 커뮤니티, 개발자 도구, 그리고 라이브러리를 만들기를 원하는 사람들을 위한 것입니다. 우리는 여러분이 러스트 언어에 기여하는 것을 정말 원합니다.

속도와 안정성을 소중하게 생각하는 사람

러스트는 언어에서 속도와 안정성을 간절히 기원하는 사람들을 위한 것입니다. 여기서 속도란, 여러분이 러스트를 가지고 만들 수 있는 프로그램의 속도와 러스트가 여러분들로 하여금 이를 작성하게 하는 속도를 의미하는 것입니다. 러스트 컴파일러의 검사들은, 이런 검사들을 가지고 있지 않은 언어라서 개발자들이 고치기를 꺼려하는 불안정한 레거시 코드들과는 반대로 기능 추가 및 리팩토링을 통해 안정성을 보장해줍니다. 비용 없는 추상화, 더 낮은 수준의 코드를 수동으로 작성한 코드만큼 빠르게 컴파일해주는 더 높은 수준의 기능을 위해 고군분투함으로서, 러스트는 안정적인 코드가 또한 빠른 코드가 되도록 노력합니다.

비록 모든 이들이 러스트 언어가 지원하기를 바라는 완벽한 리스트를 우리가 제공하지는 않을지라도, 우리가 언급해온 이들은 가장 큰 이해당사자들의 일부입니다. 종합적으로, 러스트의 가장 큰 야망은 프로그래머들이 수십 년간 받아들여 온 트레이드오프의 이분법을 제거하는 것입니다: 안정성 생산성, 속도 인간공학을 말이지요. 러스트에게 기회를 주고, 이 선택이 여러분에게도 작동하는지 알아보세요.

이 책은 누구를 위한 것인가요?

이 책은 여러분이 다른 프로그래밍 언어로 코드를 작성해 본적은 있다고 가정하지만, 그게 언어인지에 대해서는 어떠한 가정도 하지 않습니다. 우리는 이 교재가 다양한 종류의 프로그래밍 배경으로부터 온 이들에게 널리 접근될 수 있도록 시도해 왔습니다. 우리는 무엇이 프로그래밍인지, 혹은 프로그래밍에 대해 어떻게 생각해야하는지에 대하여 많은 시간을 쓰지 않습니다. 만일 여러분이 프로그래밍에 대해 완전히 초보라면, 특별히 프로그래밍에 대한 소개를 제공하는 책을 읽는 것이 더 좋을 것입니다.

이 책을 이용하는 방법

일반적으로, 이 책은 여러분이 앞에서부터 뒤로 순차적으로 읽고 있음을 가정합니다. 뒤편의 장들은 그 이전의 장들의 개념 위에서 만들어지고, 그 이전의 장들은 어떤 주제에 대해 더 깊이 탐구하지 않을 수도 있습니다; 우리는 보통 이후의 장에서 그 주제에 대해 다시 이야기 합니다.

여러분은 이 책에서 두 종류의 장들을 발견할 것입니다: 개념 장과 프로젝트 장입니다. 개념 장에서는 러스트의 관점에 대해 배울 것입니다. 프로젝트 장에서는 여러분이 여태껏 배운 것을 적용하여, 함께 작은 프로그램을 만들어볼 것입니다. 2, 12, 20장은 프로젝트 장입니다; 나머지는 개념 장입니다.

추가적으로, 2장은 러스트 언어에 대한 직접 해 보는 소개입니다. 우리는 개념들을 높은 수준에서 다루고, 이후 장들에서는 추가적인 디테일을 제공할 것입니다. 만일 여러분이 바로 손에 흙을 묻히고 싶다면, 2장은 그런 이들을 위한 장입니다. 여러분은 심지어 처음부터 다른 프로그래밍 언어 특성과 유사한 러스트 특성을 다루는 3장을 건너뛰고, 러스트의 소유권 시스템을 배우는 4장으로 직행하고 싶어 할지도 모릅니다. 하지만, 여러분이 만약 다음으로 넘어가기 전에 모든 디테일을 공부하기를 선호하는 특별히 꼼꼼한 학습자라면, 여러분은 2장을 건너뛰어 3장으로 곧바로 간 다음, 학습한 디테일들을 프로젝트에 적용해보기 위해 2장으로 돌아오는 것을 원할 수도 있습니다.

5장은 구조체와 메소드를, 6장은 열거형과 match 표현식, 그리고 if let 흐름 제어문을 다룹니다. 여러분들은 러스트 내에서 커스텀 타입을 만들기 위해 구조체와 열거형을 이용할 것입니다.

7장에서는 여러분의 코드와 공개적인 API(Application Programming Interface)를 조직화하기 위한 러스트의 모듈 시스템 및 접근 권한 규칙에 대해 배울 것입니다. 8장에서는 벡터, 스트링, 해쉬맵과 같은 표준 라이브러리에서 제공하는 일반적인 컬렉션 데이터 구조를 다룹니다. 9장에서는 러스트의 에러 처리 철학과 기술에 대해 탐구합니다.

10장에서는 제네릭, 트레잇, 그리고 라이프타임에 대해 깊이 파보는데, 이는 여러분에게 여러 개의 타입에 대하여 적용되는 코드를 정의하는 힘을 줍니다. 11장은 테스트에 관한 모든 것을 다루는데, 이는 러스트의 안정성 보장에도 불구하고 여러분의 프로그램 로직이 옳음을 확실히 하기 위해 여전히 필요합니다. 12장에서, 우리는 파일 내에서 텍스트를 검색하는 grep 커맨드 라인 도구가 제공하는 기능의 일부를 직접 구현해 볼 것입니다. 이를 위하여, 우리는 이전 장에서 다루었던 수많은 개념들을 이용할 것입니다.

13장에서는 클로저와 반복자에 대해 탐구합니다: 함수형 프로그래밍 언어에서부터 온 러스트의 특성입니다. 14장에서는 Cargo를 더 깊이 조사하고 여러분의 라이브러리를 다른 사람들과 공유하는 최고의 관례들에 대해 이야기하겠습니다. 15장에서는 표준 라이브러리가 제공하는 스마트 포인터와 이 기능을 가능케 하는 트레잇에 대해 다룹니다.

16장에서는 동시성 프로그래밍의 서로 다른 모델들을 알아보고 러스트가 어떤 식으로 다수의 쓰레드를 겁 없이 프로그래밍할 수 있도록 해주는지 이야기 하겠습니다. 17장에서는 아마도 여러분이 친숙할 수 있는 객체 지향 프로그래밍 원칙과 러스트의 표현 양식이 어떤 차이가 있는지 보겠습니다.

18장은 패턴과 패턴 매칭에 대한 참고자료인데, 이 패턴 및 패턴 매칭은 러스트 프로그램 전체를 통틀어 아이디어를 표현하는 강력한 방식입니다. 19장은 다양한 고급 주제를 뷔페처럼 담고 있는데, 이를테면 unsafe 러스트와 라이프타임, 트레잇, 타입, 함수, 그리고 클로저에 대한 추가적인 주제를 포함하고 있습니다.

20장에서는 저수준 멀티쓰레드 웹서버를 구현하는 것으로 프로젝트를 완성할 것입니다!

마지막으로, 몇 개의 부록들은 언어에 대한 유용한 정보들을 참고자료 같은 형식으로 담고 있습니다. 부록 A는 러스트의 키워드를 다룹니다. 부록 B는 러스트의 연산자와 심볼을 다룹니다. 부록 C는 표준 라이브러리가 제공하는 추론 가능한 (derivable) 트레잇들을 다룹니다. 부록 D는 매크로를 다룹니다.

이 책을 읽는 잘못된 방식이란 없습니다: 만일 여러분이 건너뛰기를 원한다면, 그렇게 하세요! 만일 여러분이 어떠한 혼란이라도 경험한다면 다시 이전 장들로 돌아와야 할지도 모릅니다. 하지만 어떻게 하든 여러분 몫입니다.

러스트를 배우는 과정의 중요한 부분은 컴파일러가 표시해주는 에러 메시지를 어떻게 읽는지를 배우는 것입니다: 이는 여러분들을 작동하는 코드로 향해 안내해줄 것입니다. 그렇기 때문에, 우리는 컴파일이 되지 않은 다양한 예제 코드와 함께 그러한 상황에서 컴파일러가 여러분에게 보여줄 에러 메시지를 제공할 것입니다. 만일 여러분이 입문하여 임의의 예제를 실행한다면, 그게 컴파일 안 될 수도 있음을 알아두세요! 여러분이 실행하기를 시도하는 그 예제가 에러를 의도한 것인지를 알아보기 위해서 그 주변의 텍스트를 읽어주세요. 대부분의 경우, 우리는 컴파일 되지 않는 어떤 코드의 올바른 버전으로 여러분을 이끌어갈 것입니다.

소스 코드

이 책을 제작하도록 하는 소스코드는 GitHub에서 찾을 수 있습니다.

시작하기

여러분의 러스트 여정을 시작해봅시다! 이 장에서는 다음을 다룰 것입니다:

  • Linux, macOS, Windows에 러스트 설치하기
  • "Hello, world!"를 출력하는 프로그램 작성하기
  • 러스트의 패키지 매니저이자 빌드 시스템인 cargo 사용하기

설치하기

첫 번째 단계는 러스트를 설치하는 것입니다. 우리는 rustup이라고 하는 러스트 버전 및 관련 도구들을 관리하기 위한 커멘드 라인 도구를 통하여 러스트를 다운로드할 것입니다. 다운로드를 위해서는 인터넷 연결이 필요할 것입니다.

다음 단계들이 러스트 컴파일러의 최신 안정 버전을 설치합니다. 이 책에 나오는 모든 예제들과 출력들은 안정화된 러스트 1.21.0을 사용했습니다. 러스트의 안정성에 대한 보장은 책에 나오는 모든 예제들이 새로운 러스트 버전에서도 계속해서 잘 컴파일 되도록 해줍니다. 버전마다 출력이 약간씩 다를 수도 있는데, 이는 러스트가 종종 에러 메시지와 경고들을 개선하기 때문입니다. 바꿔 말하면, 이 단계들을 이용하여 여러분이 설치한 러스트가 어떤 새로운 안정화 버전이라도 이 책의 내용에 기대하는 수준으로 동작해야 합니다.

커맨드 라인 표기법

이 장 및 책 곳곳에서, 우리는 터미널에서 사용되는 몇몇 커맨드를 보여줄 것입니다. 여러분이 터미널에 입력해야 하는 라인들은 모두 $로 시작합니다. 여러분은 $ 문자를 입력할 필요가 없습니다; 이는 각 커맨드의 시작을 나타냅니다. 여러분이 일반 사용자로서 실행할 커맨드를 위해 $를 그리고 여러분이 관리자로서 실행할 커맨드를 위해 #를 쓰는 관례는 많은 튜토리얼들이 사용합니다. $로 시작하지 않는 라인들은 보통 이전 커맨드의 출력을 나타냅니다. 추가적으로, 파워쉘 한정 예제는 $ 대신 >를 이용할 것입니다.

Linux와 macOS에서 Rustup 설치하기

만일 여러분들이 Linux 혹은 macOS를 사용중이라면, 터미널을 열고 다음 커멘드를 입력하세요:

$ curl https://sh.rustup.rs -sSf | sh

이 커맨드는 스크립트를 다운로드하고 rustup 도구의 설치를 시작하는데, 이 도구는 가장 최신의 러스트 안정화 버전을 설치해줍니다. 여러분의 패스워드를 입력하라는 프롬프트가 나올 수도 있습니다. 설치가 성공적이면, 다음과 같은 라인이 나타날 것입니다:

Rust is installed now. Great!

물론 여러분이 어떤 소프트웨어를 설치하기 위해 curl URL | sh를 사용하는 것을 신용하지 않는다면, 여러분이 원하는 어떤 방식으로든 이 스크립트를 다운로드하고, 검사하고, 실행할 수 있습니다.

설치 스크립트는 여러분의 다음 로그인 이후에 러스트를 자동적으로 여러분의 시스템 패스에 추가합니다. 만일 여러분이 터미널을 재시작하지 않고 러스트를 바로 사용하기를 원한다면, 다음과 같은 커멘트를 쉘에서 실행하여 수동적으로 러스트를 시스템 패스에 추가하세요:

$ source $HOME/.cargo/env

혹은 그 대신에, 여러분의 ~/.bash_profile에 다음과 같은 라인을 추가할 수 있습니다:

$ export PATH="$HOME/.cargo/bin:$PATH"

추가적으로, 여러분은 어떤 종류의 링커가 필요할 것입니다. 이미 설치되어 있을 것 같지만, 여러분이 러스트 프로그램을 컴파일하다가 링커를 실행할 수 없음을 나타내는 에러를 보게 되면, 링커를 설치해야 합니다. 여러분은 C 컴파일러를 설치할 수 있는데, 이것이 보통 올바른 링커와 함께 설치되기 때문입니다. C 컴파일러를 인스톨하는 방법을 위해서는 여러분의 플랫폼 문서를 확인하세요. 몇몇의 일반적인 러스트 패키지는 C 코드에 의존적이고 C 컴파일러 또한 사용할 것이므로, 지금 상황에 상관없이 하나 설치하는것이 좋을 수도 있습니다.

Windows에서 Rustup 설치하기

Windows에서는 https://www.rust-lang.org/en-US/install.html 페이지로 가서 러스트 설치를 위한 지시를 따르세요. 설치의 몇몇 지점에서, 여러분이 Visual Studio 2013이나 이후 버전용 C++ 빌드 도구 또한 설치할 필요가 있음을 설명하는 메세지를 받을 것입니다. 이 빌드 도구를 얻는 가장 쉬운 방법은 Visual Studio 2017용 빌드 도구를 설치하는 것입니다. 이 도구들은 다른 도구 및 프레임워크 섹션 내에 있습니다.

이 책의 나머지 부분에서는 cmd.exe 및 파워쉘 모두에서 동작하는 커멘드를 사용합니다. 만일 특별히 다른 부분이 있다면, 어떤 것을 이용하는지 설명할 것입니다.

Rustup 없이 커스텀 설치하기

만일 여러분이 어떤 이유로 rustup를 쓰지 않기를 선호한다면, the Rust installation page 페이지에서 다른 옵션을 확인하세요.

업데이트 및 설치 제거하기

rustup을 통해 러스트를 설치한 뒤라면, 최신 버전을 업데이트하는 것은 쉽습니다. 여러분의 쉘에서 다음과 같은 업데이트 스크립트를 실행하세요:

$ rustup update

러스트와 rustup을 제거하려면 다음과 같은 설치 제거용 스크립트를 쉘에서 실행하세요:

$ rustup self uninstall

문제 해결하기

러스트가 올바르게 설치되었는지를 확인하기 위해서는, 쉘을 열고 다음 라인을 입력하세요:

$ rustc --version

버전 번호, 커밋 해쉬, 그리고 배포된 최신 안정 버전에 대한 커밋 일자가 다음과 같은 형식으로 보여야 합니다:

rustc x.y.z (abcabcabc yyyy-mm-dd)

이 정보가 보인다면, 여러분은 러스트를 성공적으로 설치한 것입니다! 만일 이 정보가 보이지 않고 Windows를 이용중이라면, %PATH% 시스템 변수 내에 러스트가 있는지 확인해주세요. 만일 이 설정이 모두 정확하고 러스트가 여전히 동작하지 않는다면, 여러분이 도움을 구할 수 있는 몇 군데의 장소가 있습니다. 가장 쉬운 방법은 irc.mozilla.org 안에 있는 #rust IRC 채널인데, 이는 Mibbit을 통해 접속할 수 있습니다. 이 주소에서 여러분을 도와줄 수 있는 다른 러스티시안(Rustacean, 우리가 스스로를 부르는 우스운 별명입니다)들과 채팅을 할 수 있습니다. 다른 훌륭한 리소스들에는 유저 포럼Stack Overflow가 있습니다.

로컬 문서

인스톨러에는 또한 문서 복사본이 로컬에 포함되어 있으므로, 여러분은 이를 오프라인으로 읽을 수 있습니다. 여러분의 브라우저에서 로컬 문서를 열려면 rustup doc을 실행하세요.

표준 라이브러리가 제공하는 타입이나 함수가 무엇을 하는지 혹은 어떻게 사용하는지 확신이 들지 않는다면 언제라도 API (application programming interface) 문서를 이용하여 알아보세요!

Hello, World!

여러분이 러스트를 설치했으니, 이제 여러분의 첫번째 러스트 프로그램을 작성해봅시다. 새로운 언어를 배울 때면 “Hello, world!”라는 텍스트를 스크린에 출력하는 짧은 프로그램을 작성하는 것이 전통이니, 우리도 여기서 그렇게 할 것입니다!

노트: 이 책은 커맨드 라인에 대한 기본적인 친숙성을 가정하고 있습니다. 러스트는 여러분의 코드 수정, 도구 사용, 혹은 어디에 여러분의 코드가 있는지에 대한 어떠한 특별 요구도 없으므로, 커맨드 라인 대신 IDE (Integrated Development Environment, 통합 개발 환경)를 이용하는 것은 선호한다면, 여러분이 좋아하는 IDE를 편히 이용하세요. 이제 많은 IDE들이 어느 정도 수준의 러스트 지원을 해줍니다; 자세한 사항은 해당 IDE의 문서를 확인하세요. 최근에는 러스트 팀이 훌륭한 IDE 지원을 활성화하는데 집중해왔으며, 매우 급격한 진전이 이루어지고 있습니다!

프로젝트 디렉토리 만들기

여러분의 러스트 코드를 저장하기 위한 디렉토리를 만드는 것으로 시작할 것입니다. 여러분의 코드가 어디에 있는지는 러스트에게 문제가 되지 않습니다만, 이 책의 예제 및 프로젝트들을 위해서, 우리는 여러분의 홈 디렉토리에 projects 디렉토리를 만들고 모든 프로젝트를 그곳에 유지하는 것을 제안합니다.

터미널을 열고 다음 커맨드를 입력하여 projects 디렉토리를 만들고 projects 디렉토리 내에 “Hello, world!” 프로젝트를 위한 디렉토리를 만드세요.

Linux와 macOS에서는 다음을 입력하세요:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

Windows CMD에서는 다음을 입력하세요:

> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world

Windows 파워쉘에서는 다음을 입력하세요:

> mkdir $env:USERPROFILE\projects
> cd $env:USERPROFILE\projects
> mkdir hello_world
> cd hello_world

러스트 프로그램을 작성하고 실행하기

다음으로, main.rs이라 불리우는 새로운 소스 파일을 만드세요. 러스트 파일들은 언제나 .rs 확장자로 끝납니다. 만일 여러분이 한 단어 이상을 여러분의 파일에 사용하겠다면, 단어 구분을 위해서 언더스코어(_)를 사용하세요. 예를 들면, helloworld.rs 보다는 hello_world.rs를 사용하세요.

이제 여러분이 방금 만든 main.rs을 열고 Listing 1-1의 코드를 입력하세요.

Filename: main.rs

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

Listing 1-1: “Hello, world!”를 출력하는 프로그램

파일을 저장하고, 여러분의 터미널 윈도우로 돌아가세요. Linux나 macOS에서는 다음 커맨드를 입력하여 파일을 컴파일하고 실행하세요:

$ rustc main.rs
$ ./main
Hello, world!

Windows에서는 ./main 대신 .\main.exe 커맨드를 입력하세요.

> rustc main.rs
> .\main.exe
Hello, world!

여러분의 운영체제와 상관없이, Hello, world! 문자열이 터미널에 출력되어야 합니다. 만일 여러분이 이 출력을 보지 못한다면, “문제 해결하기”절로 돌아가서 도움을 구할 방법을 참조하세요.

Hello, world!이 출력되었다면, 축하드립니다! 여러분은 공식적으로 러스트 프로그램을 작성하셨어요. 즉 러스트 프로그래머가 되셨다는 말이지요! 환영합니다!

러스트 프로그램 해부하기

여러분의 “Hello, world!” 프로그램에서 어떤 일이 벌어졌는지를 상세하게 짚어보겠습니다. 여기 첫번째 퍼즐 조각이 있습니다:

fn main() {

}

이 라인들은 러스트의 *함수(function)*를 정의합니다. main 함수는 특별합니다: 이것은 모든 실행가능한 러스트 프로그램 내에서 첫번째로 실행되는 코드입니다. 첫번째 라인은 파라미터가 없고 아무것도 반환하지 않는 main이라는 이름의 함수를 정의합니다. 만일 파라미터가 있었다면, 파라미터들이 괄호 기호 () 내에 위치했을 것입니다.

또한 함수의 본체가 중괄호 기호 {}로 감싸져 있음을 주목하세요. 러스트는 모든 함수 본체들 주위에 이것들을 요구합니다. 여는 중괄호 기호를 함수 정의부와 같은 줄에 한 칸 띄워서 위치시키는 것은 좋은 스타일입니다.

이 글을 쓰는 시점에서 rustfmt라 불리우는 자동 포맷팅 도구가 개발중에 있습니다. 만일 여러분이 러스트 프로젝트를 가로지르는 표준 스타일을 고수하길 원한다면, rustfmt가 여러분의 코드를 특정한 스타일로 포매팅해줄 것입니다. 러스트 팀은 궁극적으로 이 도구가 rustc처럼 표준 러스트 배포에 포함되기를 계획하고 있습니다. 따라서 여러분이 이 책을 언제 읽는가에 따라써, 이 툴이 여러분의 컴퓨터에 이미 설치되어 있을지도 모릅니다! 더 자세한 사항에 대해서는 온라인 문서를 참고하세요.

main 함수 내부에는 다음과 같은 코드가 있습니다:


# #![allow(unused_variables)]
#fn main() {
    println!("Hello, world!");
#}

이 라인이 이 짧은 프로그램 내의 모든 일을 합니다: 스크린에 텍스트를 출력합니다. 여기에 주목할만 한 네 가지의 중요한 디테일이 있습니다. 첫째로, 러스트 스타일은 탭이 아닌 네 개의 스페이스로 들여쓰기를 합니다.

둘째로, println!은 러스트 매크로 (macro) 라고 불립니다. 만일 대신에 함수라고 불리려면, (! 없이) println으로 입력되었어야 할 것입니다. 러스트 매크로에 대한 자세한 사항은 부록 D에서 다룰 것입니다. 지금은 !이 보통의 함수 대신 매크로를 호출하고 있음을 의미한다는 것만 알아두면 됩니다.

셋째로, 여러분은 "Hello, world!" *스트링 (string)*을 볼 수 있습니다. 우리는 이 스트링을 println!의 인자로 넘기고, 이 스트링이 화면에 출력됩니다.

넷째로, 우리는 이 라인을 세미콜론 ;으로 끝내는데, 이는 이 표현식이 끝났고 다음 것이 시작될 준비가 되었음을 나타냅니다. 대다수의 러스트 코드 라인들이 세미콜론으로 끝납니다.

컴파일과 실행은 개별적인 단계입니다

여러분이 이제 막 새로 만든 프로그램을 실행했으므로, 이 과정의 각 단계를 검토해 봅시다.

러스트 프로그램을 실행하기 전에, 여러분은 아래와 같이 rustc 커맨드를 입력하고 여기에 여러분의 소스코드를 넘기는 식으로 러스트 컴파일러를 사용하여 이를 컴파일해야 합니다:

$ rustc main.rs

만일 여러분이 C 혹은 C++ 배경지식을 갖고 있다면, 이것이 gcc 혹은 clang과 유사하다는 것을 눈치챘을 것입니다. 컴파일을 성공적으로 한 뒤, 러스트는 실행가능한 바이너리를 출력합니다.

Linux, macOS, 그리고 Windows의 파워쉘 상에서는 여러분의 쉘에 다음과 같이 ls 커맨드를 입력하여 이 실행 파일을 볼 수 있습니다:

$ ls
main  main.rs

Windows의 CMD 환경에서는 다음과 같이 입력해야 합니다:

> dir /B %= the /B option says to only show the file names =%
main.exe
main.pdb
main.rs

이 커맨드는 .rs 확장자를 가진 소스 코드 파일, 실행 파일 (Windows에서는 main.exe, 다른 모든 플랫폼에서는 main), 그리고 만일 CMD를 이용하는 중이라면, .pdb 확장자를 가지고 있는 디버깅 정보를 담고 있는 파일을 보여줍니다. 여기서 여러분은 아래와 같이 main 혹은 main.exe 파일을 실행합니다:

$ ./main # or .\main.exe on Windows

만일 main.rs가 여러분의 “Hello, world!” 프로그램이었다면, 위의 라인이 여러분의 터미널에 Hello, world!라고 출력해줄 것입니다.

여러분이 루비, 파이썬, 자바스크립트와 같은 동적 언어에 더 친숙하다면, 아마도 프로그램의 컴파일과 실행을 개별적인 단계로 이용하지 않았을지도 모릅니다. 러스트는 ahead-of-time compiled 언어인데, 이는 여러분이 프로그램을 컴파일하고, 그 실행파일을 다른 이들에게 주면, 그들은 러스트를 설치하지 않고도 이를 실행할 수 있다는 의미입니다. 만일 여러분이 누군가에게 .rb, .py 혹은 .js 파일을 준다면, 그는 (각각) 루비, 파이썬, 혹은 자바스크립트 구현체가 설치되어 있어야 합니다. 하지만 그러한 언어들에서는 하나의 커맨드로 여러분의 프로그램을 컴파일하고 실행할 수 있습니다. 언어 디자인에서는 모든 것이 트레이드 오프입니다.

간단한 프로그램에 대해 그낭 rustc만으로 컴파일하는 것은 괜찮지만, 여러분의 프로젝트가 커지면서, 여러분은 모든 옵션을 관리하고 여러분의 코드를 공유하기 쉽도록 하길 원할 것입니다. 다음 절에서 우리는 여러분에게 Cargo 도구를 소개할 것인데, 이것이 여러분의 실생활 러스트 프로그램 작성을 도와줄 것입니다.

Hello, Cargo!

Cargo(카고)는 러스트의 빌드 시스템 및 패키지 매니저입니다. 대부분의 러스트인들이 이 도구를 이용하여 그들의 러스트 프로젝트를 관리하는데, 그 이유는 Cargo가 여러분의 코드를 빌드하고, 여러분의 코드가 의존하고 있는 라이브러리를 다운로드해주고, 그 라이브러리들을 빌드하는 등 여러분을 위한 많은 작업들을 다루기 때문입니다. (여러분의 코드가 필요로 하는 라이브러리를 의존성 (dependency) 이라고 부릅니다)

여러분이 이제껏 작성한 것과 같은 가장 단순한 러스트 프로그램은 어떠한 의존성도 없습니다. 따라서 만일 Cargo를 가지고 “Hello, world!” 프로젝트를 빌드했다면, 여러분의 코드를 빌드하는 것을 다루는 카고의 일부분만일 이용하게 되었을 것입니다. 여러분이 더 복접한 러스트 프로그램을 작성할 때면, 여러분은 의존성을 추가할 것이고, 여러분이 Cargo를 이용하여 프로젝트를 시작한다면, 의존성 추가가 훨씬 더 하기 쉬워질 것입니다.

압도적인 숫자의 러스트 프로젝트가 Cargo를 이용하기 때문에, 이 책의 나머지 부분에서는 여러분 또한 Cargo를 이용하고 있음을 가정합니다. 만일 여러분이 “설치하기” 절에서 다룬대로 공식 인스톨러를 이용했다면 Cargo는 러스트와 함께 설치되어 있습니다. 만일 여러분이 다른 수단을 통해 러스트를 설치했다면, Cargo가 설치되어 있는지 확인하기 위해서 여러분의 터미널에 다음을 입력해보세요:

$ cargo --version

버전 숫자가 보인다면, 가지고 있는 것입니다! command not found 같은 에러를 보게 된다면, 여러분이 설치한 방법에 대한 문서에서 Cargo를 개별적으로 어떻게 설치하는지 찾아보세요.

Cargo를 사용하여 프로젝트 생성하기

Cargo를 사용하여 새 프로젝트를 만들고 우리의 원래 “Hello, world!” 프로젝트와 얼마나 차이가 나는지 살펴봅시다. 여러분의 projects 디렉토리로 (혹은 여러분의 코드를 저장하기로 결정한 어느 곳이든) 이동하세요. 그 다음, 어떤 운영체제이든 상관없이 다음을 실행하세요:

$ cargo new hello_cargo --bin
$ cd hello_cargo

첫번째 커맨드는 hello_cargo라고 불리우는 새로운 실행 가능한 바이너리를 생성합니다. cargo new에게 넘겨지는 --bin 인자가 라이브러리가 아닌 실행 가능한 애플리케이션으로 만들어줍니다 (흔히들 그냥 바이너리 (binary) 라고 부릅니다). 우리의 프로젝트는 hello_cargo 라고 이름지었고, Cargo는 동일한 이름의 디렉토리에 이 프로젝트의 파일들을 생성합니다.

hello_cargo 디렉토리로 가서 파일 리스트를 보세요. 여러분은 Cargo가 우리를 위해 두 개의 파일과 하나의 디렉토리를 생성한 것을 볼 수 있을 것입니다: Cargo.toml 파일 및 안에 main.rs 파일을 담고 있는 src 디렉토리가 그것입니다. 안에는 또한 .gitignore과 함께 새로운 Git 저장소도 초기화되어 있습니다.

노트: Git은 보편적인 버전 관리 시스템입니다. 여러분은 --vcs 플래그를 사용하여 cargo new가 다른 버전 관리 시스템을 사용하거나 혹은 버전 관리 시스템을 사용하지 않도록 변경할 수 있습니다. 사용 가능한 옵션을 보려면 cargo new --help를 실행하세요.

Cargo.toml을 여러분이 원하는 텍스트 에디터로 여세요. 이 파일은 Listing 1-2의 코드와 유사하게 보여야 합니다.

Filename: Cargo.toml

[package]
name = "hello_cargo"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]

[dependencies]

Listing 1-2: cargo new가 생성한 Cargo.toml 내용

이 파일은 TOML (Tom’s Obvious, Minimal Language) 포맷으로 작성되었는데, 이것이 Cargo의 환경설정 포맷입니다.

첫번째 라인 [package]은 이후의 문장들이 패키지 환경설정이라는 것을 나타내는 섹션의 시작지점입니다. 우리가 이 파일에 더 많은 정보를 추가하기 위해, 다른 섹션들을 추가할 것입니다.

그 다음 세 라인들은 Cargo가 여러분의 프로그램을 컴파일하기 위해 필요로 하는 정보에 대한 설정을 합니다: 이름, 버전, 그리고 누가 작성했는가 입니다. Cargo는 여러분의 환경으로부터 여러분의 이름과 이메일 정보를 얻어내므로, 만일 그 정보가 정확하지 않다면, 지금 수정하고 파일을 저장하세요.

마지막 라인 [dependencies]은 여러분 프로젝트의 의존성들의 리스트를 적을 수 있는 섹션의 시작점입니다. 러스트에서는 코드의 패키지를 크레이트 (crate) 라고 부릅니다. 이 프로젝트를 위해서는 어떤 다른 크레이트도 필요없지만, 2장의 첫 프로젝트에서는 필요할 것이므로, 그때 이 의존성 섹션을 사용하겠습니다.

이제 src/main.rs을 열어서 살펴봅시다:

Filename: src/main.rs

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

Cargo는 우리가 Listing 1-1에서 작성했던 것과 똑같이 여러분을 위해 “Hello, world!” 프로그램을 작성해놨습니다! 여기까지, 우리의 이전 프로젝트와 Cargo가 만든 프로젝트 간의 차이점은 Cargo가 코드를 src 디렉토리 안에 위치시킨다는 점, 그리고 최상위 디렉토리에 Cargo.toml 환경 파일을 가지게 해준다는 점입니다.

Cargo는 여러분의 소스 파일들이 src 디렉토리 안에 있을 것으로 예상합니다. 최상위 프로젝트 디렉토리는 그저 README 파일들, 라이센스 정보, 환경 파일들, 그리고 여러분의 코드와는 관련이 없는 다른 것들 뿐입니다. Cargo를 이용하는 것은 여러분이 프로젝트를 조직화하는 데에 도움을 줍니다. 모든 것을 위한 공간이 있고, 모든 것은 자신의 공간 안에 있습니다.

만일 여러분이 Hello, world! 프로젝트에서 했던 것처럼 Cargo를 사용하지 않은 프로젝트를 시작했다면, Cargo를 사용한 프로젝트로 이를 바꿀 수 있습니다. 프로젝트 코드를 src 디렉토리로 옮기고 적합한 Cargo.toml 파일을 생성하세요.

Cargo 프로젝트를 빌드하고 실행하기

이제 Cargo로 만든 “Hello, world!” 프로젝트를 빌드하고 실행할 때의 차이점을 살펴봅시다! hello_cargo 디렉토리에서, 다음 커맨드를 입력하는 것으로 여러분의 프로젝트를 빌드하세요:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs

이 커맨드는 여러분의 현재 디렉토리 대신 target/debug/hello_cargo에 (혹은 Windows에서는 target\debug\hello_cargo.exe에) 실행 파일을 생성합니다. 여러분은 아래 커맨드를 통해 이 실행 파일을 실행할 수 있습니다:

$ ./target/debug/hello_cargo # or .\target\debug\hello_cargo.exe on Windows
Hello, world!

만일 모든 것이 잘 진행되었다면, 터미널에 Hello, world!가 출력되어야 합니다. 처음으로 cargo build를 실행하는 것은 또한 Cargo가 최상위 디렉토리에 Cargo.lock 이라는 새로운 파일을 생성하도록 합니다. 이 프로젝트는 어떠한 의존성도 가지고 있지 않으므로, 파일의 내용이 얼마 없습니다. 여러분이 이 파일을 손수 변경할 필요는 전혀 없습니다; Cargo가 여러분을 위해 이 파일의 내용을 관리합니다.

우리는 그저 cargo build로 프로젝트를 빌드하고 ./target/debug/hello_cargo로 이를 실행했지만, 또한 cargo run를 사용하여 한번의 커맨드로 코드를 컴파일한 다음 결과 실행파일을 실행할 수 있습니다:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/hello_cargo`
Hello, world!

이번에는 Cargo가 hello_cargo를 컴파일하는 중이었다는 것을 나타내는 출력을 볼 수 없음을 주목하세요. Cargo는 파일들이 변경된 적이 없음을 알아내고, 따라서 해당 바이너리를 그저 실행했을 뿐입니다. 만일 여러분이 여러분의 코드를 수정한 적 있다면, Cargo는 그 프로젝트를 실행하기 전에 다시 빌드할 것이고, 여러분은 아래와 같은 출력을 보게될 것입니다:

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
     Running `target/debug/hello_cargo`
Hello, world!

Cargo는 또한 cargo check라고 하는 커맨드를 제공합니다. 이 커맨드는 여러분의 코드가 컴파일되는지를 빠르게 확인해주지만 실행파일을 생성하지는 않습니다:

$ cargo check
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

왜 여러분이 실행파일을 원치 않게 될까요? 종종 cargo checkcargo build에 비해 훨씬 빠른데, 그 이유는 이 커맨드가 실행파일을 생성하는 단계를 생각혀기 때문입니다. 만일 여러분이 코드를 작성하는 동안 계속적으로 여러분의 작업물을 검사하는 중이라면, cargo check를 이용하는 것이 그 과정의 속도를 높여줄 것입니다! 그런 이유로, 많은 러스트인들이 자신들의 프로그램을 작성하면서 이것이 컴파일 되는지 확인하기 위해 주기적으로 cargo check을 실행합니다. 그런 다음 실행파일을 사용할 준비가 되었을 때 cargo build를 실행합니다.

여태까지 Cargo에 대하여 우리가 배운 것들을 정리하자면:

  • 우리는 cargo buildcargo check를 사용하여 프로젝트를 빌드할 수 있습니다.
  • 우리는 cargo run를 사용하여 단숨에 프로젝트를 빌드하고 실행할 수 있습니다.
  • 우리 코드가 있는 동일한 디렉토리에 빌드의 결과물이 저장되는 대신, Cargo는 이를 target/debug 디렉토리에 저장합니다.

Cargo를 사용하면 생기는 추가적인 장점은 여러분이 어떠한 운영체제로 작업을 하든 상관없이 커맨드들이 동일하다는 점입니다. 따라서 이러한 점 때문에 우리는 더 이상 Linux와 macOS 및 Windows를 위한 특정 명령을 제공하지 않을 것입니다.

릴리즈 빌드

여러분의 프로젝트가 마침내 배포(릴리즈)를 위한 준비가 되었다면, cargo build --release를 사용하여 최적화와 함께 이를 컴파일할 수 있습니다. 이 커맨드는 target/debug 대신 target/release에 실행파일을 생성할 것입니다. 최적화는 여러분의 러스트 코드를 더 빠르게 만들어주지만, 최적화를 켜는 것은 여러분의 프로그램을 컴파일하는데 드는 시간을 길게 할 것입니다: 이것이 바로 두 개의 서로 다른 프로파일이 있는 이유입니다: 하나는 여러분이 빠르게 그리고 자주 다시 빌드하기를 원하는 개발용, 그리고 다른 하나는 반복적으로 다시 빌드를 할 필요 없고 가능한 빠르게 실행되어 여러분이 사용자들에게 제공할 최종 프로그램을 빌드하기 위한 용도입니다. 만일 여러분이 코드의 실행 시간을 벤치마킹 중이라면, cargo build --release를 실행하고 target/release의 실행파일을 가지고 밴치마킹하고 있음을 확인하세요.

관례로서의 Cargo

단순한 프로젝트와 함께 Cargo를 사용하는 것은 그냥 rustc을 이용하는 것에 비해 큰 가치를 제공해주지는 못합니다만, 여러분의 프로그램이 점점 더 복잡해질수록 Cargo는 자신의 가치를 증명할 것입니다. 여러 개의 크레이트들로 구성된 복잡한 프로젝트와 함께라면 Cargo가 빌드를 조직화하도록 하는것이 훨씬 쉽습니다.

비록 hello_cargo 프로젝트가 단순했을지라도, 이 프로젝트는 이제 여러분의 남은 러스트 경력 생활 내에 사용하게될 진짜배기 도구를 사용하였습니다. 사실, 어떤 기존 프로젝트들 상에서 작업을 하기 위해서, 여러분은 Git을 사용하여 코드를 체크 아웃하고 그 프로젝트 디렉토리로 가서 빌드하기 위해 다음 커맨드를 사용할 수 있습니다:

$ git clone someurl.com/someproject
$ cd someproject
$ cargo build

Cargo에 대해 더 많은 정보를 보려면 문서를 참고하세요.

정리

여러분은 이미 여러분의 러스트 여정에서 아주 좋은 출발을 하고 있습니다! 이 장에서는 아래 항목들을 어떻게 하는지에 대해 배웠습니다:

  • rustup을 사용하여 최신의 안정화된 러스트 버전 설치하기
  • 더 최근에 나온 러스트 버전으로 업데이트하기
  • 로컬에 설치된 문서 열기
  • rustc를 직접 사용하여 “Hello, world!” 프로그램을 작성하고 실행하기
  • Cargo의 관례를 사용하여 새로운 프로젝트를 만들고 실행하기

이제 러스트 코드를 읽고 쓰는데 익숙해지기 위해서 좀더 상당한 프로그램을 빌드하기 좋은 시간입니다. 따라서 다음 장에서는 추리 게임 프로그램을 빌드해 볼 것입니다. 만약 그보다 러스트에서 어떻게 보편적인 프로그래밍 개념이 동작하는지를 배우는 것으로 시작하길 원한다면, 3장을 먼저 보시고 2장으로 돌아오세요.

추리 게임

실습 프로젝트를 통해 러스트를 사용해 봅시다. 이번 장은 실제 프로젝트에서 몇몇 일반적인 Rust 개념이 어떻게 활용되는지를 소개하려 합니다. 이 과정에서 let, match, 메소드, 연관함수(assiciated functions), 외부 크레이트(external crates) 등의 활용 방법을 배울 수 있습니다. 이런 개념들은 다음 장들에서 더 자세히 다뤄질 것입니다. 이번 장에서는 여러분이 직접 기초적인 내용을 실습합니다.

우리는 고전적인 입문자용 프로그래밍 문제인 추리 게임을 구현해 보려 합니다. 이 프로그램은 1~100 사이의 임의의 정수를 생성합니다. 다음으로 플레이어가 프로그램에 추리한 정수를 입력합니다. 프로그램은 입력받은 추리값이 정답보다 높거나 낮은지를 알려줍니다. 추리값이 정답이라면 축하 메세지를 보여주고 종료됩니다.

새로운 프로젝트를 준비하기

새로운 프로젝트를 준비하기 위해 1장에서 생성했던 디렉토리인 projects 로 이동하고 아래 예제처럼 Cargo를 이용하여 새로운 프로젝트를 생성합니다.

$ cargo new guessing_game --bin
$ cd guessing_game

첫 명령문인 cargo new는 프로젝트의 이름 (guessing_game)을 첫번째 인자로 받습니다. --bin 플래그는 Cargo가 1장과 비슷하게 바이너리용 프로젝트를 생성하도록 합니다. 두번째 명령문은 작업 디렉토리를 새로운 프로젝트의 디렉토리로 변경합니다.

생성된 Cargo.toml 파일을 살펴봅시다.

Filename: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]

[dependencies]

만약 Cargo가 환경변수에서 가져온 author 정보가 잘못되었다면 파일을 수정하고 저장하면 됩니다.

1장에서 보았듯이 cargo new는 여러분을 위해 "Hello, world!" 프로그램을 생성합니다. src/main.rs 파일을 살펴보면 다음과 같습니다.

Filename: src/main.rs

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

이제 이 "Hello, world!" 프로그램을 cargo run 명령문을 이용하여 컴파일하고 실행해 봅시다.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50 secs
     Running `target/debug/guessing_game`
Hello, world!

run 명령어는 이번 실습 프로젝트처럼 빠르게 반복(iteration)을 하고 싶을 때 유용합니다. 우리는 다음 iteration으로 넘어가기 전 빠르게 각 iteration을 테스트하고 싶습니다.

src/main.rs 를 다시 열어 두세요. 이 파일에 모든 코드를 작성할 것입니다.

추리값을 처리하기

프로그램의 첫 부분은 사용자 입력 요청, 입력값의 처리 후 입력값이 기대하던 형식인지 검증합니다. 첫 시작으로 플레이어가 추리한 값을 입력받을 수 있게 할 것입니다. Listing 2-1의 코드를 src/main.rs 에 작성하세요.

Filename: src/main.rs

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin().read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

Listing 2-1: 사용자가 추리한 값을 입력 받아 그대로 출력하는 코드

이 코드에 담긴 다양한 정보를 하나씩 살펴 보겠습니다. 사용자 입력을 받고 결과값을 표시하기 위해서는 io (input/output) 라이브러리를 스코프로 가져와야 합니다. io 라이브러리는 std라고 불리는 표준 라이브러리에 있습니다.

use std::io;

러스트는 모든 프로그램의 스코프에 prelude 내의 타입들을 가져옵니다. 만약 여러분이 원하는 타입이 prelude에 없다면 use문을 활용하여 명시적으로 그 타입을 가져와야 합니다. std::io는 사용자의 입력을 받는 것을 포함하여 io와 관련된 기능들을 제공합니다.

1장에서 보았듯이 main 함수는 프로그램의 진입점입니다.

fn main() {

fn 문법은 새로운 함수를 선언하며 ()는 인자가 없음을 나타내고 {는 함수 본문의 시작을 나타냅니다.

1장에서 배웠듯이 println!은 string을 화면에 표시하는 매크로입니다.

println!("Guess the number!");

println!("Please input your guess.");

이 코드는 게임에 대한 설명과 사용자의 입력을 요청하는 글자를 표시합니다.

값을 변수에 저장하기

다음으로 우리는 다음 코드처럼 사용자의 입력값을 저장할 공간을 생성할 수 있습니다.

let mut guess = String::new();

이제 프로그램이 점점 흥미로워지고 있습니다! 이 짧은 라인에서 여러 일들이 벌어집니다. 이 라인이 변수 를 생성하는 let문임을 주목하세요. 다음 코드도 변수를 선언하는 예시입니다.

let foo = bar;

이 라인은 foo라는 변수를 선언하고 bar라는 값과 묶습니다. 러스트에서 변수는 기본적으로 불변입니다. 다음 예시는 변수 앞에 mut을 이용하여 가변변수를 만드는 법을 보여줍니다.


# #![allow(unused_variables)]
#fn main() {
let foo = 5; // immutable
let mut bar = 5; // mutable
#}

Note: // 문법은 현재 위치부터 라인의 끝까지 주석임을 나타냅니다. 러스트는 주석의 모든 내용을 무시합니다.

이제 let mut guessguess라는 이름의 가변변수임을 알 수 있습니다. =의 반대편의 값은 guess와 묶이게 되는데 이번 예시에서는 함수 String::new의 결과값인 새로운 String 인스턴스가 묶이는 대상이 됩니다. String은 표준 라이브러리에서 제공하는 확장 가능한(growable) UTF-8 인코딩의 문자열 타입입니다.

::new에 있는 ::newString 타입의 연관함수 임을 나타냅니다. 연관함수는 하나의 타입을 위한 함수이며, 이 경우에는 하나의 String 인스턴스가 아니라 String 타입을 위한 함수입니다. 몇몇 언어에서는 이것을 정적 메소드 라고 부릅니다.

new 함수는 새로운 빈 String을 생성합니다. new 함수는 새로운 값을 생성하기 위한 일반적인 이름이므로 많은 타입에서 찾아볼 수 있습니다.

요약하자면 let mut guess = String::new(); 라인은 새로운 빈 String 인스턴스와 연결된 가변변수를 생성합니다.

프로그램에 첫번째 라인에 use std::io; 를 이용하여 표준 라이브러리의 input/output 기능을 포함한 것을 떠올려 보세요. 이제 우리는 io의 연관함수인 stdin을 호출합니다.

io::stdin().read_line(&mut guess)
    .expect("Failed to read line");

만약 프로그램 시작점에 use std::io가 없다면 함수 호출 시 std::io::stdin처럼 작성해야 합니다. stdin 함수는 터미널의 표준 입력의 핸들(handle)의 타입인 std::io::Stdin의 인스턴스를 돌려줍니다.

코드의 다음 부분인 .read_line(&mut guess)는 사용자로부터 입력을 받기 위해 표준 입력 핸들에서 .read_line(&mut guess) 메소드를 호출합니다. 또한 read_line&mut guess 를 인자로 하나 넘깁니다.

read_line은 사용자가 표준 입력에 입력할 때마다 입력된 문자들을 하나의 문자열에 저장하므로 인자로 값을 저장할 문자열이 필요합니다. 그 문자열 인자는 사용자 입력을 추가하면서 변경되므로 가변이어야 합니다.

&는 코드의 여러 부분에서 데이터를 여러 번 메모리로 복사하지 않고 접근하기 위한 방법을 제공하는 참조자 임을 나타냅니다. 참조자는 복잡한 특성으로서 러스트의 큰 이점 중 하나가 참조자를 사용함으로써 얻는 안전성과 용이성입니다. 이 프로그램을 작성하기 위해 참조자의 자세한 내용을 알 필요는 없습니다. 4장에서 참조자에 대해 전체적으로 설명할 것입니다. 지금 당장은 참조자가 변수처럼 기본적으로 불변임을 알기만 하면 됩니다. 따라서 가변으로 바꾸기 위해 &guess가 아니라 &mut guess로 작성해야 합니다.

아직 이 라인에 대해 다 설명하지 않았습니다. 한 라인처럼 보이지만 사실은 이 라인과 논리적으로 연결된 라인이 더 있습니다. 두번째 라인은 다음 메소드입니다.

.expect("Failed to read line");

.foo() 형태의 문법으로 메소드를 호출할 경우 긴 라인을 나누기 위해 다음 줄과 여백을 넣는 것이 바람직합니다. 위 코드를 아래처럼 쓸 수도 있습니다.

io::stdin().read_line(&mut guess).expect("Failed to read line");

하지만 하나의 긴 라인은 가독성이 떨어지므로 두 개의 메소드 호출을 위한 라인으로 나누는 것이 좋습니다. 이제 이 라인이 무엇인지에 대해 이야기해 봅시다.

Result 타입으로 잠재된 실패 다루기

이전에 언급한 것처럼 read_line은 우리가 인자로 넘긴 문자열에 사용자가 입력을 저장할 뿐 아니라 하나의 값을 돌려 줍니다. 여기서 돌려준 값은 io::Result 입니다. 러스트는 표준 라이브러리에 여러 종류의 Result 타입을 가지고 있습니다. 제네릭 Result이나 io:Result가 그 예시입니다.

Result 타입은 열거형(enumerations)로써 enums 라고 부르기도 합니다. 열거형은 정해진 값들만을 가질 수 있으며 이러한 값들은 열거형의 variants 라고 부릅니다. 6장에서 열거형에 대해 더 자세히 다룹니다.

Result의 variants는 OkErr입니다. Ok는 처리가 성공했음을 나타내며 내부적으로 성공적으로 생성된 결과를 가지고 있습니다. Err는 처리가 실패했음을 나타내고 그 이유에 대한 정보를 가지고 있습니다.

이러한 Result는 에러 처리를 위한 정보를 표현하기 위해 사용됩니다. Result 타입의 값들은 다른 타입들처럼 메소드들을 가지고 있습니다. io::Result 인스턴스는 expect 메소드를 가지고 있습니다. 만약 io::Result 인스턴스가 Err일 경우 expect 메소드는 프로그램이 작동을 멈추게 하고 expect에 인자로 넘겼던 메세지를 출력하도록 합니다. 만약 read_line 메소드가 Err를 돌려줬다면 그 에러는 운영체제로부터 생긴 에러일 경우가 많습니다. 만약 io::ResultOk 값이라면 expectOk가 가지고 있는 결과값을 돌려주어 사용할 수 있도록 합니다. 이 경우 결과값은 사용자가 표준 입력으로 입력했던 바이트의 개수입니다.

만약 expect를 호출하지 않는다면 컴파일은 되지만 경고가 나타납니다.

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `std::result::Result` which must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: #[warn(unused_must_use)] on by default

러스트는 read_line가 돌려주는 Result 값을 사용하지 않았음을 경고하며 일어날 수 있는 에러를 처리하지 않았음을 알려줍니다. 이 경고를 없애는 옳은 방법은 에러를 처리하는 코드를 작성하는 것이지만 만약 문제가 발생했을 때 프로그램이 멈추길 바란다면 expect를 사용할 수 있습니다. 9장에서 에러가 발생했을 때 이를 처리하는 방법에 대해 배웁니다.

println! 변경자(placeholder)를 이용한 값 출력

지금까지 작성한 코드에서 닫는 중괄호 말고도 살펴봐야 하는 코드가 하나 더 있습니다. 내용은 아래와 같습니다.

println!("You guessed: {}", guess);

이 라인은 사용자가 입력한 값을 저장한 문자열을 출력합니다. {}는 변경자로써 값이 표시되는 위치를 나타냅니다. {}를 이용하여 하나 이상의 값을 표시할 수도 있습니다. 첫번째 {}는 형식 문자열(format string) 이후의 첫번째 값을 표시하며, 두번째 {}는 두번째 값을 나타내며 이후에도 비슷하게 작동합니다. 다음 코드는 println!을 이용하여 여러 값을 표시하는 방법을 보여줍니다.


# #![allow(unused_variables)]
#fn main() {
let x = 5;
let y = 10;

println!("x = {} and y = {}", x, y);
#}

이 코드는 x = 5 and y = 10을 출력합니다.

첫번째 부분을 테스트하기

추리 게임의 처음 부분을 테스트 해 봅시다. cargo run을 통해 실행할 수 있습니다.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

지금까지 게임의 첫번째 부분을 작성했습니다. 우리는 입력값을 받고 그 값을 출력했습니다.

비밀번호를 생성하기

다음으로 사용자가 추리하기 위한 비밀번호를 생성해야 합니다. 게임을 다시 하더라도 재미있도록 비밀번호는 매번 달라야 합니다. 게임이 너무 어렵지 않도록 1에서 100 사이의 임의의 수를 사용합시다. 러스트는 아직 표준 라이브러리에 임의의 값을 생성하는 기능이 없습니다. 하지만 러스트 팀에서는 rand 크레이트를 제공합니다.

크레이트(Crate)를 사용하여 더 많은 기능 가져오기

크레이트는 러스트 코드의 묶음(package)임을 기억하세요. 우리가 만들고 있는 프로젝트는 실행이 가능한 binary crate 입니다. rand crate는 다른 프로그램에서 사용되기 위한 용도인 library crate 입니다.

Cargo에서 외부 크레이트의 활용이 정말 멋진 부분입니다. rand를 사용하는 코드를 작성하기 전에 Cargo.toml 을 수정하여 rand 크레이트를 의존 리스트에 추가해야 합니다. 파일을 열고 Cargo가 여러분을 위해 생성한 [dependencies] 절의 시작 바로 아래에 다음 내용을 추가하세요.

Filename: Cargo.toml

[dependencies]

rand = "0.3.14"

Cargo.toml 파일에서 하나의 절의 시작 이후의 모든 내용은 그 절에 포함되며 이는 다음 절이 나타날 때까지 동일합니다. [dependencies] 절은 여러분의 프로젝트가 의존하고 있는 외부 크레이트와 각각의 요구 버전을 Cargo에 명시하는 곳입니다. 지금의 경우 우리는 rand 크레이트의 유의적 버전인 0.3.14을 명시했습니다. Cargo는 버전을 명시하는 표준에 해당하는 Semantic Versioning(semver)을 이용합니다. 0.3.14^0.3.14의 축약형이 되며 이는 버전 0.3.14와 호환되는 API를 제공하는 모든 버전임을 의미합니다.

이제 Listing 2-2처럼 코드 수정 없이 프로젝트를 빌드 해 봅시다.

$ cargo build
    Updating registry `https://github.com/rust-lang/crates.io-index`
 Downloading rand v0.3.14
 Downloading libc v0.2.14
   Compiling libc v0.2.14
   Compiling rand v0.3.14
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs

Listing 2-2: rand 크레이트를 의존성으로 추가한 후 cargo build 를 실행한 결과

여러분은 다른 버전명이나 라인의 순서가 다르게 보일 수 있습니다. 버전명이 다르더라도 SemVer 덕분에 현재 코드와 호환될 것입니다.

이제 우리는 외부 의존성을 가지게 되었고, Cargo는 Crates.io 데이터의 복사본인 레지스트리(registry) 에서 모든 것들을 가져옵니다. Crates.io는 러스트의 생태계의 개발자들이 다른 사람들도 이용할 수 있도록 러스트 오픈소스를 공개하는 곳입니다.

레지스트리를 업데이트하면 Cargo는 [dependencies] 절을 확인하고 아직 여러분이 가지고 있지 않은 것들을 다운 받습니다. 이 경우 우리는 rand만 의존한다고 명시했지만 randlibc에 의존하기 때문에 libc도 다운 받습니다. 러스트는 이것들을 다운받은 후 컴파일 하여 의존성이 해결된 프로젝트를 컴파일합니다.

만약 아무것도 변경하지 않고 cargo build를 실행한다면 어떠한 결과도 얻지 못합니다. Cargo는 이미 의존 패키지들을 다운받고 컴파일했음을 알고 있고 여러분이 Cargo.toml 를 변경하지 않은 것을 알고 있습니다. 또한 Cargo는 코드가 변경되지 않은 것도 알고 있기에 코드도 다시 컴파일하지 않습니다. 아무것도 할 일이 없기에 그냥 종료될 뿐입니다. 만약 여러분이 src/main.rs 파일을 열어 사소한 변경을 하고 저장한 후 다시 빌드를 한다면 한 라인이 출력됨을 확인할 수 있습니다.

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)

이 라인은 Cargo가 src/main.rs 의 사소한 변경을 반영하여 빌드를 업데이트 했음을 보여줍니다. 의존 패키지가 변경되지 않았으므로 Cargo는 이미 다운받고 컴파일된 것들을 재사용할 수 있음을 알고 있습니다. 따라서 Cargo는 여러분의 코드에 해당하는 부분만을 다시 빌드합니다.

재현 가능한 빌드를 보장하는 Cargo.lock

Cargo는 여러분뿐만이 아니라 다른 누구라도 여러분의 코드를 빌드할 경우 같은 산출물이 나오도록 보장하는 방법을 가지고 있습니다. Cargo는 여러분이 다른 의존성을 추가하지 전까지는 여러분이 명시한 의존 패키지만을 사용합니다. 예로 rand 크레이트의 다음 버전인 v0.3.15에서 중요한 결함이 고쳐졌지만 당신의 코드를 망치는 변경점(regression) 이 있다면 어떻게 될까요?

이 문제의 해결책은 여러분이 처음 cargo build를 수행할 때 생성되어 이제 guessing_game 디렉토리 내에 존재하는 Cargo.lock 입니다. 여러분이 처음 프로젝트를 빌드할 때 Cargo는 기준을 만족하는 모든 의존 패키지의 버전을 확인하고 Cargo.lock 에 이를 기록합니다. 만약 여러분이 미래에 프로젝트를 빌드할 경우 Cargo는 모든 버전들을 다시 확인하지 않고 Cargo.lock 파일이 존재하는지 확인하여 그 안에 명시된 버전들을 사용합니다. 이는 여러분이 재현가능한 빌드를 자동으로 가능하게 합니다. 즉 여러분의 프로젝트는 Cargo.lock 덕분에 당신이 명시적으로 업그레이드하지 않는 이상 0.3.14를 이용합니다.

크레이트를 새로운 버전으로 업그레이드하기

만약 당신이 정말 크레이트를 업데이트하고 싶은 경우를 위해 Cargo는 update 명령어를 제공합니다. 이것은 Cargo.lock 파일을 무시하고 Cargo.toml 에 여러분이 명시한 요구사항에 맞는 최신 버전을 확인합니다. 만약 이 버전들로 문제가 없다면 Cargo는 해당 버전을 Cargo.lock 에 기록합니다.

하지만 Cargo는 기본적으로 0.3.0보다 크고 0.4.0보다 작은 버전을 찾을 것입니다. 만약 rand 크레이트가 새로운 두 개의 버전인 0.3.150.4.0을 릴리즈했다면 여러분이 cargo update를 실행했을 때 다음의 메세지를 볼 것입니다.

$ cargo update
    Updating registry `https://github.com/rust-lang/crates.io-index`
    Updating rand v0.3.14 -> v0.3.15

이 시점에 여러분은 Cargo.lock 파일에서 변경이 일어난 것과 앞으로 사용될 rand 크레이트의 버전이 0.3.15임을 확인할 수 있습니다.

만약 여러분이 0.4.0이나 0.4.x에 해당하는 모든 버전을 받고 싶다면 Cargo.toml 을 다음과 같이 업데이트해야 합니다.

[dependencies]

rand = "0.4.0"

다음번에 여러분이 cargo build를 실행하면 Cargo는 가용 가능한 크레이트들의 레지스트리를 업데이트할 것이고 여러분의 rand 요구사항을 새롭게 명시한 버전에 따라 재계산할 것입니다.

Cargo그의 생태계에 대해 더 많은 것들은 14장에서 다뤄지지만 지금 당장은 이 정도만 알면 됩니다. Cargo는 라이브러리의 재사용을 쉽게 하여 러스트 사용자들이 많은 패키지들과 결합된 더 작은 프로젝트들을 작성할 수 있도록 도와줍니다.

임의의 숫자를 생성하기

이제 rand사용 해 봅시다. 다음 단계는 src/main.rs 를 Listing 2-3처럼 업데이트하면 됩니다.

Filename: src/main.rs

extern crate rand;

use std::io;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("The secret number is: {}", secret_number);

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin().read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

Listing 2-3: 임의의 숫자를 생성하기 위해 필요한 코드

우리는 extern crate rand;을 추가하여 러스트에게 우리가 외부에 의존하는 크레이트가 있음을 알립니다. 이 라인은 use rand으로도 표기할 수 있으며 이제 우리는 rand::를 앞에 붙여 rand내의 모든 것을 호출할 수 있습니다.

다음으로 우리는 또 다른 use 라인인 use rand::Rng를 추가합니다. Rng는 정수 생성기가 구현한 메소드들을 정의한 trait이며 해당 메소드들을 이용하기 위해서는 반드시 스코프 내에 있어야 합니다. 10장에서 trait에 대해 더 자세히 다룰 것입니다.

또한 우리는 중간에 두 개의 라인을 추가합니다. rand::thread_rng 함수는 OS가 시드(seed)를 정하고 현재 스레드에서만 사용되는 특별한 정수생성기를 돌려 줍니다. 다음으로 우리는 get_range 메소드를 호출합니다. 이 메소드는 Rng trait에 정의되어 있으므로 use rand::Rng 문을 통해 스코프로 가져올 수 있습니다. gen_range 메소드는 두 개의 숫자를 인자로 받고 두 숫자 사이에 있는 임의의 숫자를 생성합니다. 하한선은 포함되지만 상한선은 제외되므로 1부터 100 사이의 숫자를 생성하려면 1101을 넘겨야 합니다.

크레이트에서 어떤 trait를 사용하고 어떤 함수나 메소드들을 호출하는 것을 아는 것은 단순히 아는 것 이 아닙니다. 각각의 크레이트의 문서에서 사용 방법을 제공합니다. Cargo의 또다른 멋진 특성은 cargo doc --open 명령어로써 로컬에서 여러분의 모든 의존 패키지들이 제공하는 문서들을 빌드해서 브라우저에 표시해 줍니다. 만약 rand 크레이트의 다른 기능들에 흥미가 있다면 cargo doc --open을 실행하고 왼쪽의 사이드바에 rand를 클릭하세요.

코드에 추가한 두 번째 라인은 비밀번호를 표시합니다. 이 라인은 우리가 프로그램을 개발 중일 때 테스트를 할 수 있도록 하지만 최종 버전에서는 삭제할 것입니다. 게임을 시작하자마자 정답을 출력하는 게임은 그다지 많지 않으니까요!

이제 프로그램을 몇 번 실행해 봅시다.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4
$ cargo run
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

매 실행마다 다른 숫자면서 1부터 100 사이의 숫자가 나타나야 합니다. 잘 하셨습니다!

비밀번호와 추리값을 비교하기

이제 우리는 입력값과 임의의 정수를 가지고 있음으로 비교가 가능합니다. Listing 2-4는 그 단계를 보여주고 있습니다.

Filename: src/main.rs

extern crate rand;

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

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("The secret number is: {}", secret_number);

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin().read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less    => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal   => println!("You win!"),
    }
}

Listing 2-4: 두 숫자를 비교한 결과 처리하기

처음으로 나타난 새로운 요소는 표준 라이브러리로부터 std::cmp::Ordering을 스코프로 가져오는 또다른 use입니다. OrderingResult와 같은 열거형이지만 Ordering의 값은 Less, Greater, Equal입니다. 이것들은 여러분이 두 개의 값을 비교할 때 나올 수 있는 결과들입니다.

그리고 나서 우리는 Ordering 타입을 이용하는 다섯 줄을 마지막에 추가 했습니다.

match guess.cmp(&secret_number) {
    Ordering::Less    => println!("Too small!"),
    Ordering::Greater => println!("Too big!"),
    Ordering::Equal   => println!("You win!"),
}

cmp 메소드는 두 값을 비교하며 비교 가능한 모든 것들에 대해 호출할 수 있습니다. 이 메소드는 비교하고 싶은 것들의 참조자를 받습니다. 여기서는 guesssecret_number를 비교하고 있습니다. cmpOrdering 열거형을 돌려줍니다. 우리는 match 표현문을 이용하여 cmpguesssecret_number를 비교한 결과인 Ordering의 값에 따라 무엇을 할 것인지 결정할 수 있습니다.

match 표현식은 arm 으로 이루어져 있습니다. 하나의 arm은 하나의 패턴match 표현식에서 주어진 값이 패턴과 맞는다면 실행할 코드로 이루어져 있습니다. 러스트는 match에게 주어진 값을 arm의 패턴에 맞는지 순서대로 확인합니다. match 생성자와 패턴들은 여러분의 코드가 마주칠 다양한 상황을 표현할 수 있도록 하고 모든 경우의 수를 처리했음을 확신할 수 있도록 도와주는 강력한 특성들입니다. 이 기능들은 6장과 18장에서 각각 더 자세히 다뤄집니다.

예제서 사용된 match 표현식에 무엇이 일어날지 한번 따라가 봅시다. 사용자가 50을 예측했다고 하고 비밀번호가 38이라 합시다. 50과 38을 비교하면 cmp 메소드의 결과는 Ordering::Greater 입니다. match 표현식은 Ordering::Greater를 값으로 받을 것입니다. 처음으로 마주하는 arm의 패턴인 Ordering::LessOrdering::Greater와 매칭되지 않으므로 첫번째 arm은 무시하고 다음으로 넘어갑니다. 다음 arm의 패턴인 Ordering::Greater확실히 Ordering::Greater와 매칭합니다! arm과 연관된 코드가 실행될 것이고 Too big가 출력될 것입니다. 이 경우 마지막 arm은 확인할 필요가 없으므로 match 표현식은 끝납니다.

하지만 Listing 2-4의 코드는 컴파일되지 않습니다. 한번 시도해 봅시다.

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:23:21
   |
23 |     match guess.cmp(&secret_number) {
   |                     ^^^^^^^^^^^^^^ expected struct `std::string::String`, found integral variable
   |
   = note: expected type `&std::string::String`
   = note:    found type `&{integer}`

error: aborting due to previous error
Could not compile `guessing_game`.

에러의 핵심은 일치하지 않는 타입 이 있다고 알려 주는 것입니다. 러스트는 강한 정적 타입 시스템을 가지고 있습니다. 하지만 타입 추론도 수행합니다. 만약 let guess = String::new()를 작성한다면 러스트는 guessString타입이어야 함을 추론할 수 있으므로 타입을 적으라고 하지 않습니다. 반대로 secret_number는 정수형입니다. 몇몇 숫자 타입들이 1과 100 사이의 값을 가질 수 있습니다. i32는 32비트 정수, u32는 32비트의 부호없는 정수, i64는 64비트의 정수이며 그 외에도 비슷합니다. 러스트는 기본적으로 우리가 다른 정수형임을 추론할 수 있는 다른 타입 정보를 제공하지 않는다면 숫자들을 i32으로 생각합니다. 이 에러의 원인은 러스트가 문자열과 정수형을 비교하지 않기 때문입니다.

최종적으로 우리는 추리값을 정수형으로 비교하기 위해 입력으로 받은 String을 정수로 바꾸고 싶을 것입니다. 이것은 main 함수 내에 다음 두 라인을 넣어서 할 수 있습니다.

Filename: src/main.rs

extern crate rand;

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

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("The secret number is: {}", secret_number);

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin().read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse()
        .expect("Please type a number!");

    println!("You guessed: {}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less    => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal   => println!("You win!"),
    }
}

두 라인은 다음과 같습니다.

let guess: u32 = guess.trim().parse()
    .expect("Please type a number!");

우리는 guess 변수를 생성했습니다. 잠깐, 이미 프로그램에서 guess라는 이름의 변수가 생성되지 않았나요? 그렇긴 하지만 러스트는 이전에 있던 guess의 값을 가리는(shadow) 것을 허락합니다. 이 특징은 종종 하나의 값을 현재 타입에서 다른 타입으로 변환하고 싶을 경우에 사용합니다. Shadowing은 우리들이 guess_strguess처럼 고유의 변수명을 만들도록 강요하는 대신 guess를 재사용 가능하도록 합니다. (3장에서 더 자세한 이야기를 다룹니다)

우리는 guessguess.trim().parse() 표현식과 묶습니다. 표현식 내의 guess는 입력값을 가지고 있던 String을 참조합니다. String 인스턴스의 trim 메소드는 처음과 끝 부분의 빈칸을 제거합니다. u32는 정수형 글자만을 가져야 하지만 사용자들은 read_line을 끝내기 위해 enter키를 반드시 눌러야 합니다. enter키가 눌리는 순간 개행문자가 문자열에 추가됩니다. 만약 사용자가 5를 누르고 enter키를 누르면 guess5\n처럼 됩니다. \n은 enter키, 즉 개행문자를 의미합니다. trim 메소드는 \n을 제거하고 5만 남도록 처리합니다.

문자열의 parse 메소드는 문자열을 숫자형으로 파싱합니다. 이 메소드는 다양한 종류의 정수형을 변환하므로 우리는 let guess: u32처럼 정확한 타입을 명시해야 합니다. guess 뒤의 콜론(:)은 변수의 타입을 명시했음을 의미합니다. 러스트는 몇몇 내장된 정수형을 가지고 있습니다. u32은 부호가 없는 32비트의 정수입니다. 이 타입은 작은 양수를 표현하기에는 좋은 선택입니다. 3장에서 다른 숫자형에 대해 배울 것입니다. 추가로 이 예시에서 명시했던 u32secret_number와의 비교는 러스트가 secret_number의 타입을 u32로 유추해야 함을 의미합니다. 이제 이 비교는 같은 타입의 두 값의 비교가 됩니다.

parse 메소드의 호출은 에러가 발생하기 쉽습니다. 만약 A👍%과 같은 문자열이 포함되어 있다면 정수로 바꿀 방법이 없습니다. "Result 타입으로 잠재된 실패 다루기"에서 read_line와 비슷하게 parse 메소드는 실패할 경우를 위해 Result 타입을 결과로 돌려 줍니다. 만약 parse 메소드가 문자열에서 정수로 파싱을 실패하여 Err Result variant를 돌려준다면 expect 호출은 게임을 멈추고 우리가 명시한 메세지를 출력합니다. 만약 parse 메소드가 성공적으로 문자열을 정수로 바꾸었다면 ResultOk variant를 돌려 받으므로 expect에서 Ok에서 얻고 싶었던 값을 결과로 받게 됩니다.

이제 프로그램을 실행해 봅시다!

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43 secs
     Running `target/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

좋습니다! 추리값 앞에 빈칸을 넣더라도 프로그램은 추리값이 76임을 파악 했습니다. 추리값이 맞을 때나 너무 클 경우, 혹은 너무 작은 경우 등 여러 종류의 입력값으로 여러 시나리오를 검증해 봅시다.

우리는 게임의 대부분이 동작하도록 처리 했지만 사용자는 한 번의 추리만 가능합니다. 반복문을 추가하여 변경해 봅시다!

반복문을 이용하여 여러 번의 추리 허용

loop 키워드는 무한루프를 제공합니다. 이것을 이용하여 사용자들에게 숫자를 추리할 기회를 더 줍니다.

Filename: src/main.rs

extern crate rand;

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

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("The secret number is: {}", secret_number);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin().read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse()
            .expect("Please type a number!");

        println!("You guessed: {}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less    => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal   => println!("You win!"),
        }
    }
}

우리는 추리값을 입력 받는 코드부터 모든 코드들을 반복문 내로 옮겼습니다. 각각의 라인이 4간격 더 들여쓰기 되어 있음을 확실히 하고 프로그램을 다시 실행 해 보세요. 프로그램이 우리가 지시에 정확히 따르다보니 새로운 문제가 생긴 것을 확인하세요. 이제 프로그램이 영원히 다른 추리값을 요청합니다! 사용자가 이 프로그램을 종료할 수 없어요!

사용자는 ctrl-C 단축키를 이용하여 프로그램을 멈출 수 있습니다. 하지만 "비밀번호를 추리값과 비교하기"에서 parse 메소드에 대해 논의할 때 언급한 방법으로 이 만족할 줄 모르는 괴물에게서 빠져나올 수 있습니다. 만약 사용자가 숫자가 아닌 답을 적는다면 프로그램이 멈춥니다. 사용자는 프로그램 종료를 위해 다음처럼 이 장점을 활용할 수 있습니다.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
     Running `target/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit
thread 'main' panicked at 'Please type a number!: ParseIntError { kind: InvalidDigit }', src/libcore/result.rs:785
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/guess` (exit code: 101)

quit를 입력하면 게임은 확실히 끝나지만 다른 입력값들 또한 마찬가지 입니다. 하지만 이것은 최소한의 차선책입니다. 우리는 정답을 입력할 경우 자동으로 게임이 끝나도록 하고 싶습니다.

정답 이후에 종료하기

사용자가 정답을 맞췄을 때 게임이 종료되도록 break문을 추가합니다.

Filename: src/main.rs

extern crate rand;

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

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("The secret number is: {}", secret_number);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin().read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse()
            .expect("Please type a number!");

        println!("You guessed: {}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less    => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal   => {
                println!("You win!");
                break;
            }
        }
    }
}

break문을 You win! 이후에 추가하여 사용자가 비밀번호를 맞췄을 때 프로그램이 반복문을 끝내도록 합니다. 반복문이 main의 마지막 부분이므로 반복문의 종료는 프로그램의 종료를 의미합니다.

잘못된 입력값 처리하기

사용자가 숫자가 아닌 값을 입력했을 때 프로그램이 종료되는 동작을 더 다듬어 숫자가 아닌 입력은 무시하여 사용자가 계속 입력할 수 있도록 해 봅시다. guessString에서 u32로 변환되는 라인을 수정하면 됩니다.

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

expect 메소드 호출을 match 표현식으로 바꾸는 것은 에러 발생 시 종료에서 처리 로 바꾸는 일반적인 방법입니다. parse 메소드가 Result 타입을 돌려주는 것과 ResultOkErr variants를 가진 열거형임을 떠올리세요. cmp 메소드의 Ordering 결과를 처리했을 때처럼 여기서 match 표현식을 사용하고 있습니다.

만약 parse가 성공적으로 문자열에서 정수로 변환했다면 결과값을 가진 Ok 를 돌려줍니다. Ok는 첫번째 arm의 패턴과 매칭하게 되고 match 표현식은 parse 가 생성한 num값을 돌려줍니다. 그 값은 우리가 생성하고 있던 새로운 guess 과 묶이게 됩니다.

만약 parse가 문자열을 정수로 바꾸지 못했다면 에러 정보를 가진 Err를 돌려줍니다. Err는 첫번째 arm의 패턴인 Ok(num)과 매칭하지 않지만 두 번째 arm의 Err(_) 와 매칭합니다. _은 모든 값과 매칭될 수 있습니다. 이 예시에서는 Err내에 무슨 값이 있던지에 관계없이 모든 Err를 매칭하도록 했습니다. 따라서 프로그램은 두 번째 arm의 코드인 continue를 실행하며, 이는 loop의 다음 반복으로 가서 또 다른 추리값을 요청하도록 합니다. 효율적으로 프로그램은 parse에서 가능한 모든 에러를 무시합니다.

이제 우리가 원하는대로 프로그램이 작동해야 합니다. cargo run을 실행해 봅시다.

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
     Running `target/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

멋집니다! 마지막에 조금 값을 조정하여 우리는 추리 게임을 끝냈습니다. 프로그램이 여전히 비밀번호를 출력하고 있다는 것을 떠올리세요. 테스트 때는 괜찮지만 게임을 망치게 됩니다. 비밀번호를 출력하는 println!을 삭제합니다. Listing 2-5는 최종 코드를 보여줍니다.

Filename: src/main.rs

extern crate rand;

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

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin().read_line(&mut guess)
            .expect("Failed to read line");

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

        println!("You guessed: {}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less    => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal   => {
                println!("You win!");
                break;
            }
        }
    }
}

Listing 2-5: 추리 게임의 완성된 코드

요약

이 시점에서 여러분은 성공적으로 추리 게임을 만들었습니다! 축하합니다!

이 프로젝트는 let, match, 메소드, 연관함수, 외부 크레이트 사용과 같은 많은 새로운 러스트 개념들을 소개하기 위한 실습이었습니다. 다음 장들에서는 이 개념들의 세부적인 내용을 배울 것입니다. 3장은 대부분의 프로그래밍 언어들이 가지고 있는 변수, 데이터 타입, 함수를 소개하고 러스트에서의 사용법을 다룹니다. 4장에서는 다른 프로그래밍 언어와 차별화된 러스트의 특성인 소유권을 다룹니다. 5장에서는 구조체와 메소드 문법을 다루며 6장에서는 열거형에 대해 다룹니다.

보편적인 프로그래밍 개념

이번 챕터에서는 모든 프로그래밍 언어가 대부분 가진 개념이 Rust에서는 어떻게 다루어지는지 알아보고자 합니다. 많은 프로그래밍 언어가 보편적인 핵심요소를 갖습니다. 이번 챕터에서 Rust 고유의 개념은 다루지 않을테지만, 보편적인 프로그래밍 개념을 Rust의 문법을 설명하는 과정에서 토의하고자 합니다.

특히 변수, 기본 타입들, 함수, 주석, 그리고 제어문에 대해서 배울 수 있을 것 입니다. 이 기본 사항들은 모든 Rust 프로그램에서 사용되며 이들을 조기에 숙지하는 것은 Rust를 시작하는데 큰 바탕이 되줄 겁니다.

Keywords

다른 언어들과 마찬가지로 Rust에도 고정된 의미를 갖는 Keywords가 있습니다. 이들은 변수나 함수명으로 사용될 수 없다는 점을 명심하세요. 대부분의 keywords가 특별한 의미를 갖고, 이들을 통해 다양한 작업을 Rust를 통해 수행할 수 있습니다; 소수의 keywords는 현재는 아무 기능도 없지만 향후 추가될 기능을 위해 예약되어 있습니다. 이들은 목록은 Appendix A에서 찾아볼 수 있습니다.

변수와 가변성

2 장에서 언급했듯이, 기본 변수는 불변성 입니다. 이것은 Rust가 제공하는 안전성과 손쉬운 동시성이라는 장점을 취할 수 있도록 코드를 작성하게끔 강제하는 요소 중 하나 입니다. 하지만 여전히 당신은 가변 변수를 사용하고 싶을테죠. 어떻게 그리고 왜 Rust에서 불변성을 애호해주길 권장하는지 알아보면 그런 생각을 포기할 수 있을지도 모르겠습니다.

변수가 불변성인 경우, 일단 값이 이름에 bound되면 해당 값을 변경할 수 없습니다. 시험 삼아 cargo new --bin variables을 실행해서 projects 디렉토리에 variables 라는 새 프로젝트를 생성 해 봅시다. 그런 다음 새 variables 디렉토리에서 src/main.rs 를 열고 코드를 다음과 같이 바꿉니다.

Filename: src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

저장하고 cargo run 명령을 통해 실행시켜 봅시다. 당신은 다음과 같이 출력되는 에러를 확인하게 될 겁니다.

error[E0384]: re-assignment of immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {}", x);
4 |     x = 6;
  |     ^^^^^ re-assignment of immutable variable

위의 예제는 컴파일러가 당신이 만든 프로그램에서 당신을 도와 에러를 찾아주는 방법에 대해 보여주고 있습니다. 컴파일러 에러가 힘빠지게 만들 수도 있지만, 단지 당신의 프로그램이 아직 안전하게 수행되긴 미흡하다는 뜻이지, 당신의 소양이 부족함을 의미하는건 아닙니다. 숙련된 Rustacean들도 여전히 에러를 발생시키니까요. 에러가 나타내는 것은 불변성 변수에 재할당이고, 원인은 우리가 불변성 변수 x에 두 번째로 값을 할당했기 때문입니다.

우리가 이전에 불변성으로 선언한 것의 값을 변경하고자 하는 시도를 하면 컴파일 타임의 에러를 얻게 되고 이로 인해 버그가 발생할 수 있기 때문에 중요합니다. 만약 우리 코드의 일부는 값이 변경되지 않는다는 것을 가정하는데 다른 코드는 이와 다르게 값을 변경한다면, 전자에 해당하는 코드는 우리가 의도한 대로 수행되지 않을 수 있습니다. 특히 후자에 해당되는 코드가 항상 그렇지 않고 가끔 값을 변경하는 경우 나중에 버그의 원인을 추적하기가 매우 어렵습니다.

Rust에서는 컴파일러가 변경되지 않은 값에 대한 보증을 해주고, 실제로 이는 바뀌지 않습니다. 이것이 의미하는 바는 당신이 코드를 작성하거나 분석할 시에 변수의 값이 어떻게 변경되는지 추적할 필요가 없기 때문에 코드를 더 합리적으로 만들어줍니다.

하지만 가변성은 매우 유용하게 사용될 수 있습니다. 변수는 기본적으로 불변성이지만 우리는 변수명의 접두어로 mut을 추가하는 것을 통해 가변성 변수를 선언할 수 있습니다. 이 변수의 값이 변경을 허용하는 것에 추가로 향후 코드를 보는 사람에게 코드의 다른 부분에서 해당 변수의 값을 변경할 것이라는 의도를 주지시킵니다.

예를 들어, src/main.rs를 다음과 같이 변경해보도록 합니다.

Filename: src/main.rs

fn main() {
    let mut x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

위의 프로그램을 수행하면 다음과 같은 결과를 얻게 됩니다:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30 secs
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

mut를 사용하여, x에 bind된 값을 5에서 6으로 변경할 수 있습니다. 불변성 변수만을 사용하는 것보다 가변성 변수를 사용하여 보다 쉽게 구현할 수 있을 경우 가변성 변수를 만들어 사용할 수도 있습니다.

이런 의사 결정에 있어서 버그를 예방하는 것 외에도 고려해야 할 요소들이 있습니다. 예를 들어, 대규모 데이터 구조체를 다루는 경우 가변한 인스턴스를 사용하는 것이 새로 인스턴스를 할당하고 반환하는 것보다 빠를 수 있습니다. 데이터 규모가 작을수록 새 인스턴스를 생성하고 함수적 프로그래밍 스타일로 작성하는 것이 더 합리적이고, 그렇기에 약간의 성능 하락을 통해 가독성을 확보할 수 있다면 더 가치있는 선택입니다.

변수와 상수 간의 차이점들

변수의 값을 변경할 수 없다는 사항이 아마 당신에게 다른 언어가 가진 프로그래밍 개념을 떠오르게 하지 않나요: 상수 불변성 변수와 마찬가지로 상수 또한 이름으로 bound된 후에는 값의 변경이 허용되지 않지만, 상수와 변수는 조금 다릅니다.

첫 째로, 상수에 대해서는 mut을 사용하는 것이 허용되지 않습니다: 상수는 기본 설정이 불변성인 것이 아니고 불변성 그 자체 입니다.

우리가 상수를 사용하고자 하면 let키워드 대신 const키워드를 사용해야 하고, 값의 유형을 선언해야 합니다. 우리가 사용할 수 있는 유형들과 유형의 선언을 챕터 “Data Types,”에서 다루게 될 것이므로 자세한 사항은 지금 걱정하지 말고, 우리는 반드시 값의 유형을 선언해야 한다는 것을 알고 지나갑시다.

상수는 전체 영역을 포함하여 어떤 영역에서도 선언될 수 있습니다. 이는 코드의 많은 부분에서 사용될 필요가 있는 값을 다루는데 유용합니다.

마지막 차이점은 상수는 오직 상수 표현식만 설정될 수 있지, 함수 호출의 결과값이나 그 외에 실행 시간에 결정되는 값이 설정될 수는 없다는 점 입니다.

아래의 MAX_POINTS라는 이름을 갖는 상수를 선언하는 예제에서는 값을 100,000으로 설정합니다. (Rust의 상수 명명 규칙에 따라 모든 단어를 대문자로 사용합니다.)


# #![allow(unused_variables)]
#fn main() {
const MAX_POINTS: u32 = 100_000;
#}

상수는 자신이 선언되어 있는 영역 내에서 프로그램이 실행되는 시간 동안 항상 유효하기에, 당신의 어플리케이션 도메인 전체에 걸쳐 프로그램의 다양한 곳에서 사용되는 값을 상수로 하면 유용합니다. 사용자가 한 게임에서 획득할 수 있는 최대 포인트, 빛의 속도 같은 값 등등...

당신의 프로그램 전체에 걸쳐 하드코드 해야 하는 값을 이름지어 상수로 사용하면 향후 코드를 유지보수 하게 될 사람에게 그 의미를 전달할 수 있으므로 유용합니다. 또한 향후 해당 값을 변경해야 하는 경우에 상수로 선언된 값 한 곳만 변경하면 되므로 도움이 될 겁니다.

Shadowing

앞서 우리가 2장에서 추측 게임 예제를 통해 봤듯이, 이전에 선언한 변수와 같은 이름의 새 변수를 선언할 수 있고, 새 변수는 이전 변수를 shadows하게 됩니다. Rustaceans들은 이를 첫 변수가 두 번째에 의해 shadowed 됐다고 표현하게 됩니다. 해당 변수명은 두 번째 변수의 값을 갖게 된다는 뜻이죠. let키워드를 사용해서 다음처럼 반복하여 같은 변수 명으로 변수를 shadow 할 수 있습니다.

Filename: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    let x = x * 2;

    println!("The value of x is: {}", x);
}

이 프로그램은 처음 x에 값 5를 bind 합니다. 이후 반복된 let x = 구문으로 x를 shadow하고 원본 값에 1을 더해서 x의 값은 6이 됩니다. 세 번째 let 문으로 또 x를 shadow하고, 이전 값에 2를 곱하여 x의 최종값은 12가 됩니다. 이 프로그램을 실행하면 다음과 같은 결과를 볼 수 있습니다.

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running `target/debug/variables`
The value of x is: 12

이와 같은 사용은 변수를 mut으로 선언하는 것과는 차이가 있게 됩니다. 왜냐면 let키워드를 사용하지 않고 변수에 새로 값을 대입하려고 하면 컴파일-시에 에러를 얻게 되기 때문이죠. 우리가 몇 번 값을 변경할 수는 있지만 그 이후에 변수는 불변성을 갖게 됩니다.

또 다른 mut과 shadowing의 차이는 let키워드를 다시 사용하여 효과적으로 새 변수를 선언하고, 값의 유형을 변경할 수 있으면서도 동일 이름을 사용할 수 있다는 점 입니다. 예를 들어, 공백 문자들을 입력받아 얼마나 많은 공백 문자가 있는지 보여주고자 할 때, 실제로는 저장하고자 하는 것은 공백의 갯수일테죠.


# #![allow(unused_variables)]
#fn main() {
let spaces = "   ";
let spaces = spaces.len();
#}

이와 같은 구조가 허용되는 이유는 첫 spaces 변수가 문자열 유형이고 두 번째 spaces 변수는 첫 번째 것과 동일한 이름을 가진 새롭게 정의된 숫자 유형의 변수이기 때문입니다. Shadowing은 space_str이나 space_num 과 같이 대체된 이름을 사용는 대신 간단히 spaces 이름을 사용할 수 있게 해줍니다. 그러나 우리가 mut을 사용하려고 했다면:

let mut spaces = "   ";
spaces = spaces.len();

우리는 다음처럼 변수의 유형을 변경할 수 없다는 컴파일-시의 에러를 얻게 될 겁니다:

error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected &str, found usize
  |
  = note: expected type `&str`
             found type `usize`

변수가 어떻게 동작하는지 탐구했으니, 더 많은 데이터 유형을 살펴보도록 합시다.

데이터 타입들

Rust에서 사용되는 모든 값들은 어떤 타입을 갖습니다. 그러니 어떤 형태의 데이터인지 명시하여 Rust에게 알려줘서 이를 통해 데이터를 어떻게 다룰지 알 수 있도록 해야 합니다. 이번 장에서, 우리는 언어에 포함되어 있는 여러 타입들을 살펴보고자 합니다. 타입은 크게 스칼라와 컴파운드, 둘로 나눌 수 있습니다.

이번 장의 전체에 걸쳐 주지해야 할 점은 Rust는 타입이 고정된 언어라는 점 입니다. 이게 의미하는 바는 모든 변수의 타입이 컴파일 시에 반드시 정해져 있어야 한다는 겁니다. 보통 컴파일러는 우리가 값을 사용하는 지에 따라 타입을 추측할 수 있습니다. 2장에서 Stringparse를 사용하여 숫자로 변환했던 경우처럼 타입의 선택 폭이 넓은 경우는 반드시 타입의 명시를 첨가해야 합니다. 다음처럼:


# #![allow(unused_variables)]
#fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
#}

여기에 타입 명시를 첨가하지 않은 경우, Rust는 다음과 같은 에러를 발생시킵니다.
이와 같은 에러는 컴파일러가 우리에게 사용하고 싶은 타입이 무엇인지 추가적인 정보를 요구하는 겁니다.

error[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^
  |         cannot infer type for `_`
  |         consider giving `guess` a type

우리가 다루고자 하는 다양한 데이터 타입들 각각의 타입 명시를 살펴보겠습니다.

스칼라 타입들

스칼라는 하나의 값으로 표현되는 타입입니다. Rust는 정수형, 부동소수점 숫자, boolean, 그리고 문자, 네 가지 스칼라 타입을 보유하고 있습니다. 아마 다른 프로그래밍 언어에서도 본 적이 있겠지만, Rust에서 이들이 어떻게 동작하 는지 살펴보도록 합시다.

정수형

정수형은 소수점이 없는 숫자 입니다. 우리는 이번 장의 앞부분에서 u32타입인 정수형을 사용했었습니다. 해당 타입의 선언은 부호 없는 32비트 변수임을 나타냅니다 (부호 있는 타입은 u대신 i로 시작합니다.) 표 3-1은 Rust에서 사용되는 정수형들을 보여줍니다. 부호, 미부호로 나뉜 다른 열의 타입을 사용하여(i16처럼) 정수 값의 타입을 선언할 수 있습니다.

Table 3-1: Rust에서의 정수 타입

LengthSignedUnsigned
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
archisizeusize

각각의 타입은 부호 혹은 미부호이며 명시된 크기를 갖습니다. 부호 혹은 미부호의 의미는, 숫자가 양수 혹은 음수를 다룰 수 있는지 혹은 없는지를 나타냅니다. 다르게 말하면, 숫자가 부호를 가져야 하는 경우(부호) 혹은 오직 양수만을 가질 것이기에 부호가 없이도 표현할 수 있는가(미부호)를 나타냅니다. 종이에 숫자 기재하는 것과 같죠: 부호와 함께 다뤄야 하는 경우에 숫자는 더하기 혹은 빼기 기호와 함께 표시하죠. 숫자가 양수라고 가정되도 문제 없는 상황에는 부호가 없이 표시하게 됩니다. 부호된 숫자는 2의 보수 형태를 사용하여 저장됩니다. (2의 보수가 모른다면 검색해보세요. 이 책에서 다루는 내용이 아닙니다.)

각 부호 변수는 -(2n - 1) 부터 2n - 1 - 1 까지의 값을 포괄합니다. 여기서 n은 사용되는 타입의 비트 수 입니다. 즉, i8은 -(27) 에서 27 - 1 까지의 값, 즉 -128 에서 127 사이의 값을 저장할 수 있습니다. 미부호 타입은 0 에서 2n - 1 까지의 값을 저장할 수 있습니다. 즉, u8 타입은 0 에서 28 - 1 다시 말해, 0 에서 255 까지의 값을 저장할 수 있습니다.

추가로, isizeusize타입은 당신의 프로그램이 동작하는 컴퓨터 환경이 64-bits인지 아닌지에 따라 결정됩니다. 64-bit 아키텍처이면 64bit를, 32-bit 아키텍처이면 32bit를 갖게 됩니다.

당신은 테이블 3-2에서 보여주는 형태들처럼 정수형 리터럴을 사용할 수 있습니다. byte 리터럴을 제외하고 모든 정수형 리터럴은 57u8과 같은 타입 접미사와 1_000과 같이 시각적인 구분을 위한 _의 사용을 허용합니다.

Table 3-2: Rust의 정수형 리터럴들

Number literalsExample
Decimal98_222
Hex0xff
Octal0o77
Binary0b1111_0000
Byte (u8 only)b'A'

그렇다면 어떤 타입의 정수를 사용해야 할까요? 확실하게 정해진 경우가 아니면 Rust의 기본 값인 i32가 일반적으로 는 좋은 선택입니다. 이는 일반적으로 가장 빠르기 때문이죠. 심지어 64-bit 시스템에서도요. isizeusize는 주로 콜렉션의 정렬을 색인할 때 사용됩니다.

부동 소수점 타입

Rust에는 소수점을 갖는 숫자인 부동소수점 숫자를 위한 두 가지 기본 타입도 있습니다. Rust의 부동소수점 타입은 f32f64로, 예상하신 대로 각기 32bit와 64bit의 크기를 갖습니다. 기본 타입은 f64인데, 그 이유는 최신의 CPU 상에서는 f64f32와 대략 비슷한 속도를 내면서도 더 정밀한 표현이 가능하기 때문입니다.

다음은 부동소수점 숫자가 활용되는 예제입니다:

Filename: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

부동소수점 숫자는 IEEE-754 표준에 따라 표현됩니다. f32 타입은 1배수의 정밀도인 부동소수점이고, f64는 2배수의 정밀도인 부동소수점 입니다.

수학적 연산들.

Rust가 지원하는 일반적인 기본 수학적 연산은 기대하신 것처럼 모든 숫자 타입에 적용됩니다: 더하기, 빼기, 곱하기, 나누기 등등. 다음의 코드로 보여주려는 것은 각 경우를 let문 내에서 사용할 수 있는 방법입니다.

Filename: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;

    // remainder
    let remainder = 43 % 5;
}

위의 문장에서 각 표현식들은 수학 연산자를 사용하여 산출된 값을 변수로 bound 합니다. 부록 B에 Rust에서 제공하는 모든 연산자 목록이 들어있습니다.

Boolean 타입

대부분의 다른 언어들처럼, boolean 타입은 Rust에서 둘 중 하나의 값만 갖을 수 있습니다: truefalse. boolean 타입은 러스트에서 bool로 명시됩니다.

예제:

Filename: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

boolean 값을 사용하는 주된 방법은 if문과 같은 조건문에서 조건으로 사용하는 것입니다. 우리는 if문이 Rust에서 동작하는 방식을 “제어 흐름” 장에서 다루게 될 겁니다.

문자 타입

지금까지 숫자 타입만을 살펴봤는데, Rust는 문자 또한 지원합니다. Rust의 char는 이 언어의 가장 근본적인 알파벳 타입이고, 다음의 코드는 이를 사용하는 한 가지 방법입니다. 스트링이 큰따옴표를 쓰는 것에 반하여 char 타입은 작은따옴표로 쓰는 점을 주목하세요:

Filename: src/main.rs

fn main() {
   let c = 'z';
   let z = 'ℤ';
   let heart_eyed_cat = '😻';
}

Rust의 char타입은 Unicode Scalar를 표현하는 값이고 이는 ASCII 보다 많은 표현을 가능하게 합니다. 억양 표시가 있는 문자, 한국어/중국어/일본어 표의 문자, 이모티콘, 넓이가 0인 공백문자 모두가 Rust에서는 char타입으로 사용할 수 있습니다. Unicode Scalar 값의 범위는 U+0000에서 U+D7FF 그리고 U+E000에서 U+10FFFF 를 포괄합니다. 그럼에도 불구하고 “문자”는 Unicode을 위한 개념이 아니기 때문에, 당신의 인간적 직관에 따른 “문자”와 Rust의 char가 동일하지 않을 수 있습니다. 우리는 8장 “Strings” 부에서 이 주제에 대해 상세히 다루게 될 겁니다.

복합 타입들

복합 타입들은 다른 타입의 다양한 값들을 하나의 타입으로 묶을 수 있습니다. Rust는 두 개의 기본 타입들을 갖고 있습니다: 튜플과 배열.

값들을 집합시켜서 튜플화하기.

튜플은 다양한 타입의 몇 개의 숫자를 집합시켜 하나의 복합 타입으로 만드는 일반적인 방법입니다.

우리는 괄호 안에 콤마로 구분되는 값들의 목록을 작성하여 튜플을 만듭니다. 튜플에 포함되는 각 값의 타입이 동일할 필요없이 서로 달라도 됩니다. 다음의 예제에 우리는 선택 사항인 타입 명시를 추가했습니다.

Filename: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

튜플은 단일 요소를 위한 복합계로 고려되었기에 변수 tup에는 튜플 전체가 bind 됩니다. 개별 값을 튜플의 밖으로 빼내오기 위해서는, 패턴 매칭을 사용하여 튜플의 값을 구조해체 시키면 됩니다. 다음을 봅시다:

Filename: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {}", y);
}

해당 프로그램은 처음에 튜플을 만들고 변수 tup에 bind 시킵니다. 이후 패턴과 let을 통해 tup을 세개의 분리된 변수 x, y, 그리고 z에 이동시킵니다. 이것을 구조해체라고 부르는 이유는 하나의 튜플을 세 부분으로 나누기 때문입니다. 최종적으로 프로그램은 y의 값을 출력할 것이고 이는 6.4입니다.

패턴 매칭을 통한 구조해체에 추가로, 우리는 마침표(.) 뒤에 우리가 접근하길 원하는 값의 색인을 넣는 것을 통해 튜플의 요소에 직접적으로 접근할 수 있습니다. 예제를 봅시다:

Filename: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

위의 프로그램은 튜플 x를 만들고, 이의 각 요소들을 그들의 색인을 통해 접근하여 새 변수를 만듭니다. 대부분의 언어가 그렇듯이, 튜플의 첫 번째 색인은 0 입니다.

배열

여러 값들의 집합체를 만드는 다른 방법은 배열입니다. 튜플과는 다르게, 배열의 모든 요소는 모두 같은 타입이여야 합니다. Rust의 배열이 몇 다른 언어들의 배열과 다른 점은 Rust에서는 배열은 고정된 길이를 갖는다는 점입니다: 한번 선언되면, 이들은 크기는 커지거나 작아지지 않습니다.

Rust에서는 대괄호 안에 값들을 콤마로 구분하여 나열해서 배열을 만듭니다:

Filename: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

배열이 유용할 때는 당신의 데이터를 heap보다 stack에 할당하는 것을 원하거나(stack 과 heap에 대해서는 4장에서 다루게 될 것입니다), 당신이 항상 고정된 숫자의 요소를 갖는다고 확신하고 싶을 때 입니다. 이들은 벡터 타입처럼 가변적 이지 않습니다. 벡터 타입은 유사 집합체로 표준 라이브러리에서 제공되며 확장 혹은 축소가 가능합니다. 배열이나 벡터 중에 뭘 선택해야 할지 확실하지 않은 상황이라면 벡터를 사용하도록 하세요. 8장에서 벡터에 대해 더 자세히 다룹니다.

벡터가 아닌 배열을 선택하게 되는 경우의 예로, 프로그램이 올해의 달 이름을 알고자 할 경우 입니다. 프로그램이 달을 추가하거나 삭제하는 경우는 거의 없을 것이므로, 고정적으로 12개의 아이템을 가질테니 배열을 사용하면 됩니다.


# #![allow(unused_variables)]
#fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
#}
배열 요소에 접근하기

배열은 stack에 단일 메모리 뭉치로 할당됩니다. 우리는 색인을 통해 배열의 요소에 접근할 수 있습니다. 이렇게요:

Filename: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

이번 예제에서, first로 명명된 변수는 값 1이 될텐데, 왜냐면 배열 색인 [0]에 들어있는 값이기 때문이죠. second로 명명된 변수는 배열의 색인 [1]의 값인 2가 되겠죠.

유효하지 않은 배열 요소에 대한 접근

만약 우리가 배열의 끝을 넘어선 요소에 접근하려고 하면 어떻게 될까요? 예제를 다음처럼 변경해봤습니다.

Filename: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
    let index = 10;

    let element = a[index];

    println!("The value of element is: {}", element);
}

이번 코드를 cargo run을 통해 동작시키면 다음의 결과를 얻게 됩니다:

$ cargo run
   Compiling arrays v0.1.0 (file:///projects/arrays)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running `target/debug/arrays`
thread '<main>' panicked at 'index out of bounds: the len is 5 but the index is
 10', src/main.rs:6
note: Run with `RUST_BACKTRACE=1` for a backtrace.

컴파일 시에는 아무런 에러도 발생시키지 않습니다만, 프로그램의 결과는 실행간에 에러가 발생했고 성공적으로 종료되지 못했다고 나옵니다.

색인을 사용하여 요소에 접근하려고 하면 Rust는 지정한 색인이 배열 길이보다 작은지 확인합니다. 색인이 길이보다 길면 Rust는 프로그램이 오류와 함께 종료 될 때 Rust가 사용하는 용어인 *패닉(panic)*합니다.

이것은 Rust의 안전 원칙이 동작하는 첫 번째 예입니다. 많은 저수준 언어에서 이러한 타입의 검사는 수행되지 않으며 잘못된 색인을 제공하면 유효하지 않은 메모리에 액세스 할 수 있습니다. Rust는 메모리 접근을 허용하고 계속 진행하는 대신 즉시 종료하여 이러한 종류의 오류로부터 사용자를 보호합니다. 9 장에서는 Rust의 오류 처리에 대해 자세히 설명합니다.

함수 동작 원리

함수는 Rust에 녹아들어 있습니다. 여러분은 이미 언어에서 가장 중요하게 생각하는 main함수를 보셨습니다. 이는 다수의 프로그램에서 실행 지점입니다. 여러분은 또한 fn 키워드도 보셨을텐데, 이는 새로운 함수의 선언을 가용하게 합니다.

Rust 코드는 뱀 형태를 변수나 함수 이름의 형식 규칙으로 사용합니다. 뱀 형태에서, 모든 문자는 소문자를 사용하며 밑줄 표시로 단어를 구분합니다. 다음은 예제로 함수를 선언하는 프로그램 입니다:

Filename: src/main.rs

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

    another_function();
}

fn another_function() {
    println!("Another function.");
}

Rust 에서의 함수 선언은 fn으로 시작하며 함수 이름 뒤에 괄호의 형식으로 되어 있습니다. 중괄호는 컴파일러에게 함수의 시작과 종료 지점을 알려주게 됩니다.

우리는 함수의 이름과 괄호 형식을 기입하는 것을 통해 우리가 선언했던 어떤 함수든 호출할 수 있습니다. another_function이 프로그램 내에 정의되어 있으므로, main 함수에서 해당 함수를 호출할 수 있습니다. 주의할 점은, 소스 코드 내에서 another_functionmain 함수 뒤에 정의했다는 점 입니다. 우리는 이를 main 함수 앞에도 정의할 수 있습니다. Rust는 당신의 함수의 위치를 신경쓰지 않습니다, 어디든 정의만 되어 있으면 됩니다.

함수를 추가로 탐색하기 위해 functions 이라는 이름의 새로운 바이너리 프로젝트를 시작합시다. another_function 예제를 src/main.rs 에 넣고 실행해보세요. 다음과 같은 결과가 나타납니다:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.28 secs
     Running `target/debug/functions`
Hello, world!
Another function.

main 함수 안의 내용이 줄의 순서대로 수행됩니다. 처음으로, "Hello, world!" 메시지가 출력되고, another_function이 호출되고 그의 메시지를 출력합니다.

함수 매개변수

함수는 함수 고유한 부분인 특별한 변수 매개변수를 갖는 형식으로 선언될 수 있습니다. 함수가 매개변수를 취할 때, 우리는 상수를 그들의 전달인자로 제공할 수 있습니다. 기술적으로, 여기서 전달되는 상수를 전달인자라고 부릅니다만, 사람들은 보통 “전달인자”와 “매개변수”를 혼용해서 사용하는 경향이 있습니다.

다음의 재작성 된 another_function은 Rust에서 매개변수가 어떤 것이지 보여줍니다:

Filename: src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {}", x);
}

이 프로그램을 실행해보시면 다음과 같은 결과가 출력되는 것을 보게 될 겁니다:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 1.21 secs
     Running `target/debug/functions`
The value of x is: 5

another_function의 선언은 x로 명명된 하나의 매개변수를 갖습니다. x의 타입은 i32로 정의됩니다. 5another_function으로 전달되면, println! 매크로는 중괄호 짝으로 된 형식 문자열에 5를 전달합니다. 함수의 선언부에서, 여러분은 반드시 각 매개변수의 타입을 정의해야 합니다. 이 사항은 Rust를 설계하며 내린 신중한 결정사항 입니다: 함수의 정의에 타입을 명시하여 코드내 다른 부분에서 이들을 사용하는 것을 통해 당신의 의도를 추측하지 않아도 되게 됩니다.

여러분의 함수에 여러 개의 매개변수를 사용하고 싶으면, 매개변수들을 다음처럼 쉼표와 함께 구분해서 사용할 수 있습니다:

Filename: src/main.rs

fn main() {
    another_function(5, 6);
}

fn another_function(x: i32, y: i32) {
    println!("The value of x is: {}", x);
    println!("The value of y is: {}", y);
}

이 예제는 각각 i32 타입인 두 개의 매개변수를 갖는 함수를 생성합니다. 함수는 그의 두 매개변수의 값을 출력합니다. 주의할 점은, 함수 매개변수는 이번 예제처럼 굳이 같은 타입이 아니여도 된다는 점 입니다. 한번 코드를 실행해봅시다. 여러분의 function 프로젝트의 src/main.rs 내용을 위의 예제로 변경한 뒤에, cargo run을 통해 수행시키면 됩니다:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running `target/debug/functions`
The value of x is: 5
The value of y is: 6

우리는 값 56xy로 전달했기 때문에, 이 값들이 담긴 두 문장을 출력합니다.

함수 본문

함수 본문은 필요에 따라 표현식으로 종결되는 구문의 나열로 구성됩니다. 지금까지 우리는 종결 표현식이 없는 함수만 다뤘기에, 표현식이 구문의 일부처럼 여겨질지 모르겠습니다. Rust가 표현식에 기반한 언어기 때문에, 이것은 이해하셔야 하는 중요한 차이점 입니다. 다른 언어들은 이와 같은 차이가 없으니, 구문과 표현식이 함수의 본문에 어떤 식으로 차이나게 적용되는지 살펴보도록 하겠습니다.

구문과 표현식

사실 우리는 이미 구문과 표현식을 사용했습니다. 구문은 어떤 명령들의 나열로 값을 반환하지 않는 어떤 동작을 수행 합니다. 포현식은 결과 값을 산출해냅니다. 다음 몇 개의 예제를 살펴보도록 합시다. let 키워드를 통해 변수를 만들고 값을 할당하는 구문을 만듭니다. 항목 3-3의, let y = 6;은 구문 입니다:

Filename: src/main.rs

fn main() {
    let y = 6;
}

항목 3-3: 하나의 구문을 갖는 main 함수를 선언하였다.

함수 정의는 또 하나의 구문입니다; 상기 예제는 자신 그 자체가 구문 입니다. 구문은 값을 반환하지 않습니다. 그러니, 여러분은 다음처럼 let 구문을 사용해서는 다른 변수에 값을 대입할 수 없습니다:

Filename: src/main.rs

fn main() {
    let x = (let y = 6);
}

여러분이 이 프로그램을 수행하면, 다음과 같은 에러를 보게 될 겁니다:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found statement (`let`)
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: variable declaration using `let` is a statement

let y = 6 구문은 반환 값이 없으므로, x에 bind 시킬 것이 없습니다. 이것이 다른 언어인 C나 Ruby와의 차이점 입니다. 이들 언어들은 x = y = 6와 같은 코드가 xy에 모두 6의 값을 대입할 수 있습니다; Rust에서는 허용되지 않습니다. 여러분이 작성하는 Rust 코드의 대부분은 표현식이며 이는 어떤 값을 산출합니다. 5 + 6과 같은 간단한 수학 연산을 살펴보면, 이는 11이란 값을 산출하는 표현식입니다.

표현식은 구문의 부분일 수 있습니다: 항목 3-3은 let y = 6;이란 구문을 갖는데, 66이란 값을 산출하는 표현식입니다. 함수를 호출하는 것은 표현식입니다. 매크로를 호출하는 것은 표현식입니다. 예제처럼 새로운 범위를 생성하는데 사용하는 block {}은 표현식입니다:

Filename: src/main.rs

fn main() {
    let x = 5;

    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {}", y);
}

표현식 부:

{
    let x = 3;
    x + 1
}

이번 경우에 해당 block은 4를 산출합니다. 이 값은 let 구문의 일부로 y에 bound됩니다. 여러분이 앞서 봐온 것과 다르게 x + 1 줄의 마지막이 세미콜론으로 끝나지 않은 점을 주목하세요. 표현식은 종결을 나타내는 세미콜론을 사용하지 않습니다. 만약 세미콜론을 표현식 마지막에 추가하면, 이는 구문으로 변경되고 반환 값이 아니게 됩니다. 이후부터 함수의 반환 값과 표현식을 살펴보실때 이 점을 유의하세요.

반환 값을 갖는 함수

함수는 그들을 호출한 코드에 값을 반환할 수 있습니다. 우리는 반환되는 값을 명명해야 할 필요는 없지만, 그들의 타입은 화살표(->) 뒤에 선언해야 합니다. Rust에서 반환 값은 함수 본문의 마지막 표현식의 값과 동일합니다. return 키워드와 값을 써서 함수로부터 일찍 반환할 수 있지만, 대부분의 함수들은 암묵적으로 마지막 표현식을 반환합니다. 값을 반환하는 함수의 예를 보겠습니다:

Filename: src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("The value of x is: {}", x);
}

five 함수에는 함수 호출, 매크로, 심지어 let 구문도 없이 그저 5란 숫자 하나가 있습니다. 이는 Rust에서 완벽하게 함수로 허용됩니다. 함수 반환 값의 타입이 -> i32로 명시되어 있다는 점 또한 주목하세요. 해당 코드를 수행하면 다음과 같은 결과를 얻게 될 겁니다:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30 secs
     Running `target/debug/functions`
The value of x is: 5

5five 함수가 반환한 값이고, 이 때문에 반환 타입을 i32으로 한 것이지요. 좀더 자세히 설명해보겠습니다. 중요한 지점이 두 곳 있습니다: 첫 째, let x = five(); 줄은 우리가 반환 값을 변수의 초기 값으로 사용하는 것을 보여줍니다. five의 반환 값이 5이기 때문에, 해당 줄은 다음과 동일합니다:


# #![allow(unused_variables)]
#fn main() {
let x = 5;
#}

둘 째, five 함수는 매개변수 없이 반환 값에 대한 타입만 정의되어 있지만, 본문에는 5만이 세미콜론 없이 외로이 있는 이유는 이것이 우리가 값을 반환하고자 할때 사용하는 하는 표현식이기 때문입니다. 다른 예제를 통해 살펴보겠습니다:

Filename: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {}", x);
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

이 코드를 수행하면 The value of x is: 6를 출력하게 됩니다. 우리가 x + 1 끝에 세미콜론을 추가하여 표현식을 구문으로 변경하면 어떤 일이 일어날까요?

Filename: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {}", x);
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}

이 코드를 실행하면 다음과 같은 에러를 얻게 됩니다:

error[E0308]: mismatched types
 --> src/main.rs:7:28
  |
7 |   fn plus_one(x: i32) -> i32 {
  |  ____________________________^
8 | |     x + 1;
  | |          - help: consider removing this semicolon
9 | | }
  | |_^ expected i32, found ()
  |
  = note: expected type `i32`
             found type `()`

에러 메시지의 중요 포인트는 “mismatched types,”으로 이 코드의 주요 문제를 보여줍니다. plus_one 함수의 정의는 i32 값을 반환하겠다고 하였으나, 구문은 값을 산출하지 않기에 ()처럼 비어있는 튜플로 표현됩니다. 이런 이유로, 반환할 것이 없어서 함수가 정의된 내용과 상충하게 되고 이는 에러를 발생시킵니다. 이번 결과에서는, Rust가 문제를 해결할 수 있도록 도와주는 메시지를 제공합니다: 세미콜론을 제거하면 에러가 교정될 수도 있다고 제안하네요.

주석

모든 프로그래머들은 되도록 이해하기 쉽게 이해되는 코드를 작성하기 위해 노력하지만, 자주 부연 설명이 필요합니다. 이런 경우, 프로그래머들은 메모를 남기거나 소스코드에 컴파일러는 무시하도록 되어 있는 주석 을 남겨 소스코드를 읽는 사람이 혜택을 받을 수 있게 합니다.

여기에 간단한 주석이 있습니다:


# #![allow(unused_variables)]
#fn main() {
// Hello, world.
#}

Rust에서 주석은 두개의 슬래쉬로 시작해야 하고 해당 줄의 끝까지 계속됩니다. 한 줄을 넘는 주석을 작성할 경우, //를 각 줄에 포함시켜 사용하면 됩니다, 이런 식으로요:


# #![allow(unused_variables)]
#fn main() {
// 우리는 여기에 뭔가 복잡한 것을 적어놓고자 하는데, 그를 위해 충분히 긴 여러 줄의 주석이 필요합니다. 
// 휴! 다행입니다.
// 이 주석은 그에 대해 설명할테니까요.
#}

주석은 코드의 뒷 부분에 위치할 수도 있습니다:

Filename: src/main.rs

fn main() {
    let lucky_number = 7; // I’m feeling lucky today.
}

하지만 주석을 코드와 나눠 앞 줄에 기재되는 형식을 더 자주 보게 될 겁니다.

Filename: src/main.rs

fn main() {
    // I’m feeling lucky today.
    let lucky_number = 7;
}

러스트는 다른 종류의 주석인 문서화 주석(documentation comments)을 가지고 있는데, 이는 14장에서 논의할 것입니다.

제어문

조건의 상태가 참인지에 따라 어떤 코드의 실행 여부를 결정하거나 조건이 만족되는 동안 반복 수행을 하는 것은 대부분의 프로그래밍 언어의 기초 문법입니다. 우리가 실행 흐름을 제어할 수 있는 가장 보편적인 작성 방식은 if표현식과 반복문 입니다.

if표현식

if표현식은 우리의 코드가 조건에 따라 분기할 수 있게 합니다. 우리가 조건을 제공하는 것은 다음 서술과 같죠. “만약 조건이 충족되면, 이 코드 블럭을 실행하세요. 만약 충족되지 않았다면 코드 블럭을 실행하지 마세요."

branches로 명명된 새 프로젝트를 우리의 projects 디렉토리에 생성하고 if식을 탐구합시다. src/main.rs 파일에 다음의 내용을 기입하세요:

Filename: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

모든 if표현식은 if란 키워드로 시작하며 뒤이어 조건이 옵니다. 이번 경우에 조건은 변수 number가 5보다 작은 값을 가지는지 여부가 됩니다. 조건이 참일 때 실행하는 코드 블록은 조건 바로 뒤 중괄호로 된 블록에 배치됩니다. if식의 조건과 관련된 코드 블럭은 우리가 2장의 “비밀번호 추리 게임”에서 다뤘던 match식의 갈래(arms)와 마찬가지로 *갈래(arms)*로 불립니다. 선택적으로, 우리는 이번 경우에서 처럼 else식을 포함시킬 수 있는데, 이는 조건이 거짓으로 산출될 경우 실행시킬 코드 블럭을 프로그램에 제공합니다. 당신이 else식을 제공하지 않는데 조건이 거짓이 되면, 프로그램은 if블록을 생략하고 다음 순서의 코드를 실행하게 될 겁니다.

이 코드를 실행해보세요; 다음과 같은 결과를 얻을 수 있을 겁니다:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running `target/debug/branches`
condition was true

number의 값을 조건을 거짓으로 만들 값으로 변경하면 무슨 일이 일어날지 살펴보도록 합시다:

let number = 7;

프로그램을 다시 실행시키면, 다음과 같은 결과를 보게 됩니다:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running `target/debug/branches`
condition was false

주의해야 할 중요한 점은 이번 코드의 조건은 반드시 bool이어야 합니다. 만약 bool이 아닐 경우 어떤 일이 일어나는지는 다음의 코드를 실행하면 알 수 있을 겁니다:

Filename: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

if의 조건이 3으로 산출되고, Rust는 에러를 발생시킵니다.

error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected bool, found integral variable
  |
  = note: expected type `bool`
             found type `{integer}`

이 에러가 나타내는 것은 Rust가 bool을 기대하였으나 정수형이 왔다는 겁니다. Rust는 boolean 타입이 아닌 것을 boolean 타입으로 자동 변환하지 않습니다. Ruby나 Javascript와는 다르죠. 우리는 반드시 명시적으로 booleanif의 조건으로 사용해야 합니다. 만약 우리가 if표현식의 코드 블록을 숫자가 0이 아닐 시에 실행하고 싶다면, 다음처럼, 우리는 if표현식을 변경할 수 있습니다.

Filename: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

이번 코드를 실행시키면 number was something other than zero가 출력 될 겁니다.

else if와 다수 조건

우리는 ifelse 사이에 else if식을 추가 결합하여 다양한 조건을 다룰 수 있습니다. 예제를 보시죠:

Filename: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

이번 프로그램은 분기할 수 있는 네 개의 경로를 갖습니다. 이를 수행하면, 다음과 같은 결과를 얻게 될 겁니다:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running `target/debug/branches`
number is divisible by 3

이 프로그램이 실행될 때, if식을 차례대로 검사하고 검사 조건이 참일 때의 첫 번째 본문을 실행합니다. 주목할 점은 6을 2로 나누어 떨어짐에도 불구하고 number is divisible by 2이 출력되지 않는데, else의 블럭에 위치한 number is not divisible by 4, 3, or 2도 마찬가지입니다. 이렇게 되는 이유는 Rust가 첫 번째로 조건이 참이 되는 블록만 찾아 실행하고, 한번 찾게 되면 나머지는 검사하지 않기 때문입니다.

너무 많은 else if식의 사용은 당신의 코드를 이해하기 어렵게 하므로, 둘 이상일 경우 코드를 리팩토링하게 될 수도 있습니다. 이런 경우를 위해 6장에서 match라 불리는 강력한 분기 생성자를 다룹니다.

let구문에서 if 사용하기

if가 표현식이기 때문에, 항목 3-4에서 처럼, 우리는 이를 let 구문의 우측에 사용할 수 있죠.

Filename: src/main.rs

fn main() {
    let condition = true;
    let number = if condition {
        5
    } else {
        6
    };

    println!("The value of number is: {}", number);
}

Listing 3-4: if 표현식의 결과값을 변수에 대입하기

변수 number에는 if식에서 산출된 값이 bound되게 됩니다. 어떤 일이 일어날지 코드를 실행해보죠:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running `target/debug/branches`
The value of number is: 5

기억하세요! 코드 블록은 그들의 마지막에 위치한 표현식을 산출하며 숫자는 그 자체로 표현식이라는 것을요. 이 경우 전체 if식의 값은 실행되는 코드 블럭에 따라 다릅니다. 그렇기에 if식에 속한 각 갈래의 결과는 반드시 같은 타입이여야 합니다. 항목 3-4에서 if갈래와 else갈래는 모두 i32 정수형을 결과 값으로 가집니다. 하지만 만약 다음 예제처럼 유형이 다르면 어떻게 될까요?

Filename: src/main.rs

fn main() {
    let condition = true;

    let number = if condition {
        5
    } else {
        "six"
    };

    println!("The value of number is: {}", number);
}

우리가 이번 코드를 실행시키려고 하면 에러를 얻게 됩니다. ifelse 갈래의 값 타입이 호환되지 않고, Rust는 정확히 프로그램의 어느 지점에 문제가 있는지 보여줍니다.

error[E0308]: if and else have incompatible types
 --> src/main.rs:4:18
  |
4 |       let number = if condition {
  |  __________________^
5 | |         5
6 | |     } else {
7 | |         "six"
8 | |     };
  | |_____^ expected integral variable, found reference
  |
  = note: expected type `{integer}`
             found type `&str`

if 블록이 정수형을 산출하는 식이고 else 블록은 문자열을 산출하는 식 입니다. 이런 경우가 성립하지 않는 이유는 변수가 가질 수 있는 타입이 오직 하나이기 때문입니다. Rust는 컴파일 시에 number 변수의 타입이 뭔지 확실히! 정의해야 합니다. 그래야 number가 사용되는 모든 곳에서 유효한지 검증할 수 있으니까요. Rust는 number의 타입을 실행 시에 정의되도록 할 수 없습니다. 컴파일러가 모든 변수의 다양한 타입을 추적해서 알아내야 한다면 컴파일러는 보다 복잡해지고 보증할 수 있는 것은 적어지게 됩니다.

반복문과 반복

코드 블록을 한 번 이상 수행하는 것은 자주 유용합니다. 반복 작업을 위해서, Rust는 몇 가지 반복문을 제공합니다. 반복문은 반복문 시작부터 끝까지 수행하고 다시 처음부터 수행합니다. 반복문의 실험해보기 위해 loops으로 명명된 새 프로젝트를 작성해 봅시다.

Rust가 제공하는 세 가지 반복문: loop, while, 그리고 for을 모두 사용해 봅시다.

loop와 함께 코드의 반복 수행

loop keyword는 Rust에게 그만두라고 명시하여 알려주기 전까지 코드 블럭을 반복 수행합니다. 예제로, 우리의 loops디렉토리에 src/main.rs를 다음처럼 변경하세요:

Filename: src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

이 프로그램을 실행시키면, 우리는 프로그램을 강제 정지하기 전까지 again!이 반복 출력되는 것을 보게 됩니다. 대부분의 터미널은 단축키 ctrl-C를 통해서 무한루프에 빠진 프로그램을 정지시키는 기능을 지원합니다. 한번 시도해 보세요:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
     Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!

기호 ^C는 우리가 ctrl-C를 눌렀을 때의 위치입니다. 코드가 정지 신호를 받은 시점에 따라 ^C 이후에 again!이 출력될 수도 아닐 수도 있습니다.

다행스럽게도, Rust는 보다 안정적으로 루프에서 벗어날 수 있는 방법을 제공합니다. 우리는 break keyword 를 위치시켜 프로그램이 언제 루프를 멈춰야 하는지 알려줄 수 있습니다. 상기시켜 드리자면 2장 “추리 게임”에서 사용자가 모든 숫자를 정확히 추리했을 경우 프로그램을 종료시키기 위해 사용했었습니다.

while와 함께하는 조건부 반복

반복문 내에서 조건을 산출하는 것은 자주 유용합니다. 조건이 참인 동안 반복문을 수행합니다. 조건이 참이 아니게 된 경우에 break을 호출하여 반복을 정지시킵니다. 이런 패턴의 반복문을 구현하자면 loop, if, else, 그리고 break를 혼합해야 합니다; 원한다면 이렇게 사용해도 됩니다.

하지만, 이런 패턴은 매우 보편적이기 때문에 이와 동일한 구조자가 Rust에는 내장되어 있으며, 이를 while 반복문이라 부릅니다. 다음의 예제를 통해 while을 사용해 봅시다: 프로그램은 세 번 반복되고, 반복 때마다 카운트 다운됩니다. 마침내 반복이 끝나면 다른 메시지를 출력하고 종료됩니다:

Filename: src/main.rs

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{}!", number);

        number = number - 1;
    }

    println!("LIFTOFF!!!");
}

이 구조자는 loop, if, else 및 break를 사용하는 경우 필요한 많은 중첩을 제거하며, 더 깔끔합니다. 조건이 true인 동안 코드가 실행되고; 그렇지 않으면 루프에서 벗어납니다.

for와 함께하는 콜렉션 반복하기

우리는 while 구조자를 통해 배열과 같은, 콜렉션의 각 요소에 걸쳐 반복 수행 할 수 있습니다. 예를 들어서, Listing 3-5을 살펴봅시다:

Filename: src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("the value is: {}", a[index]);

        index = index + 1;
    }
}

Listing 3-5: while 반복문을 사용해 콜렉션의 각 요소들을 순회하기

여기서, 코드는 배열의 요소에 걸쳐 카운트를 증가시킵니다. 이 색인은 0에서 시작하고, 배열의 마지막 순서까지 반복됩니다 (즉, index < 5가 참이 아닐 때까지). 이 코드를 수행하면 배열의 모든 요소가 출력되게 됩니다.

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

예상했던 대로, 5개인 배열 모든 값이 터미널에 표시됩니다. index 값이 5에 오는 시점에, 그러니까 배열의 6번째 값에 접근하기 전에 반복은 중지되어야 합니다.

그러나 이런 방식은 에러가 발생하기 쉽습니다; 우리가 정확한 길이의 색인을 사용하지 못하면 프로그램은 패닉을 발생합니다. 또한 느린데, 이유는 컴파일러가 실행 간에 반복문을 통해 반복될 때마다 요소에 대한 조건 검사를 수행하는 런타임 코드를 추가하기 때문입니다.

보다 효율적인 대안으로, 우리는 for 반복문을 사용하여 콜렉션의 각 요소에 대한 코드를 수행할 수 있습니다. for 반복문은 다음 Listing 3-6과 같습니다:

Filename: src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a.iter() {
        println!("the value is: {}", element);
    }
}

Listing 3-6: for 반복문을 사용해 콜렉션의 각 요소를 순회하기

우리가 이 코드를 수행하면, 항목 3-5와 같은 결과를 볼 수 있습니다. 더 중요한 것은, 우리는 이제 코드의 안전성을 높이고 배열의 끝을 넘어가거나 충분한 길이를 지정하지 못해 일부 아이템이 누락되어 발생할 수있는 버그의 가능성을 제거했습니다.

예를 들어, 코드 3-5의 코드에서 a 배열 에서 항목을 제거 했지만 조건을 while index < 4로 업데이트하지 않으면 코드는 패닉을 발생합니다. for루프를 사용하면, 당신이 배열의 수를 변경 한 경우에도 다른 코드를 변경해야 할 필요가 없습니다. (역주 : 당신은 살면서 변경한 배열의 수를 기억하고 있는가?)

for반복문이 안전하고 간결하기 때문에 이들은 가장 보편적으로 사용되는 반복문 구조자입니다. 항목 3-5에서처럼 while반복문을 사용하여 특정 횟수만큼 코드를 반복하려는 경우에도, 대부분의 Rust 사용자들은 for반복문 을 사용하고자 할 것 입니다. 이런 사용을 위해 Rust에서 기본 라이브러리로 제공하는 Range를 사용하게 됩니다. Range는 한 숫자에서 다른 숫자 전까지 모든 숫자를 차례로 생성합니다.

여기 for반복문과 아직 설명하지 않은 range를 역순하는 rev메소드를 사용하는 카운트다운 프로그램이 있습니다:

Filename: src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{}!", number);
    }
    println!("LIFTOFF!!!");
}

꽤 괜찮은 코드인것 같죠?

결론

해냈어요! 무지 긴 장이었어: 우리는 변수, 스칼라, if식과 반복문까지 배웠어요! 혹시 이번 장에서 나온 내용을 연습해보고 싶으면 다음을 수행하는 프로그램을 만들어 보세요.

  • 화씨와 섭씨를 상호 변환.
  • n번째 피보나치 수열 생성.
  • 크리스마스 캐롤 “The Twelve Days of Christmas”의 가사를 반복문을 활용해 출력.

다음으로 넘어갈 준비가 되셨습니까? 우리는 이제 일반적인 다른 언어에는 존재하지 않는 개념에 대해서 다루고자 합니다 : 소유권.

소유권 이해하기

소유권(Ownership)은 러스트의 가장 유니크한 특성이며, 러스트가 가비지 콜렉터 없이 메모리 안정성 보장을 하게 해줍니다. 그러므로, 소유권이 러스트 내에서 어떻게 동작하는지 이해하는 것은 중요합니다. 이 장에서는 소유권 뿐만 아니라 이와 관련된 특성들: 빌림, 슬라이스, 그리고 러스트가 메모리에 데이터를 저장하는지 등을 알아보겠습니다.

소유권이 뭔가요?

러스트의 핵심 기능은 바로 소유권입니다. 이 기능은 직관적으로 설명할 수 있지만, 언어의 나머지 부분에 깊은 영향을 끼칩니다.

모든 프로그램은 실행하는 동안 컴퓨터의 메모리를 사용하는 방법을 관리해야 합니다. 몇몇 언어들은 프로그램이 실행될 때 더이상 사용하지 않는 메모리를 끊임없이 찾는 가비지 콜렉션을 갖고 있습니다; 다른 언어들에서는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제해야 합니다. 러스트는 제 3의 접근법을 이용합니다: 메모리는 컴파일 타임에 컴파일러가 체크할 규칙들로 구성된 소유권 시스템을 통해 관리됩니다. 소유권 기능들의 어떤 것도 런타임 비용이 발생하지 않습니다.

소유권이란 개념이 많은 프로그래머들에게 새로운 것이기 때문에, 이해하고 사용하는 데에는 약간의 시간이 걸립니다만, 좋은 소식은 여러분이 러스트와 소유권 시스템의 규칙에 더 많은 경험을 할수록, 여러분은 더 안전하고 더 효율적인 코드를 자연스럽게 개발할 수 있게될 것이라는 거죠. 견뎌내세요!

여러분이 소유권을 이해했을 때, 여러분은 러스트를 유니크하게 만드는 기능들을 이해하기 위한 견고한 기초를 가지게 될 것입니다. 이 장에서, 여러분은 매우 흔한 데이터 구조인 문자열에 집중된 몇가지 예제를 통해 소유권에 대해 배우게 될 것입니다.

스택과 힙

많은 프로그래밍 언어들 안에서, 우리는 그렇게 자주 스택과 힙에 대한 생각을 할 필요가 없습니다. 그렇지만 러스트와 같은 시스템 프로그래밍 언어에서는, 값이 스택에 있는지 힙에 있는지의 여부가 언어의 동작 방식과 우리의 결단에 더 큰 영향을 줍니다. 우리는 이 장의 뒤쪽에서 스택과 힙에 관계된 소유권의 일부분을 기술할 것이기에, 여기서는 준비 삼아 간략한 설명만 하겠습니다.

스택과 힙 둘다 여러분의 코드상에서 런타임에 사용할 수 있는 메모리의 부분입니다만, 이들은 각기 다른 방식으로 구조화 되어 있습니다. 스택은 값을 받아들인 순서대로 값을 저장하고 반대 방향으로 값을 지웁니다. 이것을 last in, first out이라고 하죠. 쌓여있는 접시를 생각해보세요; 여러분이 접시를 더 추가하려면 접시더미의 꼭대기에 쌓아올리고, 여러분이 접시가 필요해지면 꼭대기에서부터 한장 꺼내게 됩니다. 중간이나 밑에서부터 접시를 추가하거나 제거하는 건 잘 안될겁니다! 데이터를 추가하는 것을 스택에 푸시하기 (pushing on the stack)라고 부르고, 데이터를 제거하는 것을 스택을 팝하기 (popping off the stack)라고 부릅니다.

스택은 데이터에 접근하는 방식 덕택에 빠릅니다: 이 방식은 새로운 데이터를 넣어두기 위한 공간 혹은 데이터를 가져올 공간을 검색할 필요가 전혀 없는데, 바로 그 공간이 항상 스택의 꼭대기(top)이기 때문입니다. 스택을 빠르게 해주는 또다른 특성은 스택에 담긴 모든 데이터가 결정되어 있는 고정된 크기를 갖고 있어야 한다는 점입니다.

컴파일 타임에 크기가 결정되어 있지 않거나 크기가 변경될 수 있는 데이터를 위해서는, 힙에 데이터를 저장할 수 있습니다. 힙은 조금 더 복잡합니다: 데이터를 힙에 넣을때, 먼저 저장할 공간이 있는지 물어봅니다. 그러면 운영체제가 충분히 커다란 힙 안의 빈 어떤 지점을 찾아서 이 곳을 사용중이라고 표시하고, 해당 지점의 포인터를 우리에게 돌려주죠. 이 절차를 힙 공간 할당하기(allocating on the heap)라고 부르고, 종종 그냥 "할당(allocating)"으로 줄여 부릅니다. 스택에 포인터를 푸싱하는 것은 할당에 해당되지 않습니다. 포인터는 결정되어 있는 고정된 크기의 값이므로, 우리는 스택에 포인터를 저장할 수 있지만, 실제 데이터를 사용하고자 할 때는 포인터를 따라가야 합니다.

힙에 저장된 데이터에 접근하는 것은 스택에 저장된 데이터에 접근하는 것보다 느린데, 그 이유는 포인터가 가리킨 곳을 따라가야 하기 때문입니다. 현대 프로세서들은 메모리 내부를 덜 뛰어다닐 때 더 빨라집니다. 유사한 예로, 여러 테이블로부터 주문을 받는 레스토랑의 웨이터를 생각해보세요. 다음 테이블로 움직이기 전에 지금 테이블에서 모든 주문을 다 받는 것이 가장 효율적이겠죠. A 테이블에서 하나 주문 받고, 다시 B 테이블로 가서 하나 주문 받고, 다시 A로, 다시 B로 가며 하나씩 주문을 받으면 훨씬 느려질 겁니다. 이와 마찬가지로, 프로세서는 (힙에 있는 데이터와 같이) 멀리 떨어져 있는 데이터들 보다는 (스택에 있는 것과 같이) 붙어있는 데이터들에 대한 작업을 하면 더 빨라집니다. 힙으로부터 큰 공간을 할당받는것 또한 시간이 걸릴 수 있습니다.

코드의 어느 부분이 힙의 어떤 데이터를 사용하는지 추적하는 것, 힙의 중복된 데이터의 양을 최소화하는 것, 그리고 힙 내에 사용하지 않는 데이터를 제거하여 공간이 모자라지 않게 하는 것은 모두 소유권과 관계된 문제들입니다. 여러분이 소유권을 이해하고 나면, 여러분은 더이상 스택과 힙에 대한 생각이 자주 필요치 않게 될겁니다만, 힙 데이터를 관리하는 것이 곧 소유권의 존재 이유임을 알게 되는 것은 이것이 어떤 방식으로 작동하는지 설명하는데 도움을 줄 수 있습니다.

소유권 규칙

먼저, 소유권 규칙을 알아봅시다. 이것들을 설명할 예제들을 보는 내내 다음의 소유권 규칙들을 명심하세요:

  1. 러스트의 각각의 값은 해당값의 오너(owner)라고 불리우는 변수를 갖고 있다.
  2. 한번에 딱 하나의 오너만 존재할 수 있다.
  3. 오너가 스코프 밖으로 벗어나는 때, 값은 버려진다(dropped).

변수의 스코프

우리는 이미 2장에서 완성된 형태의 러스트 프로그램 예제를 살펴봤습니다. 이제 과거의 기초 문법 형태로 돌아가서, fn main() { 코드를 예제에 붙이지 않을테니, 여러분들이 코드를 따라하려면 main 함수에 직접 예제들을 넣어야 할 겁니다. 결과적으로, 우리의 예제들은 좀더 간략해저셔 보일러 플레이트 코드에 비해 실제 디테일에 초점을 맞출 수 있도록 해줄 것입니다.

소유권에 대한 첫 예제로서, 변수들의 스코프를 보겠습니다. 스코프란 프로그램 내에서 아이템이 유효함을 표시하기 위한 범위입니다. 아래처럼 생긴 변수가 있다고 해봅시다:


# #![allow(unused_variables)]
#fn main() {
let s = "hello";
#}

변수 s는 스트링 리터럴을 나타내는데, 스트링 리터럴의 값은 우리의 프로그램의 텍스트 내에 하드코딩되어 있습니다. 변수는 선언된 시점부터 현재의 스코프가 끝날 때까지 유효합니다. 아래 예제 Listing 4-1은 변수 s가 유효한 지점을 주석으로 표시했습니다:


# #![allow(unused_variables)]
#fn main() {
{                      // s는 유효하지 않습니다. 아직 선언이 안됐거든요.
    let s = "hello";   // s는 이 지점부터 유효합니다.

    // s를 가지고 뭔가 합니다.
}                      // 이 스코프는 이제 끝이므로, s는 더이상 유효하지 않습니다.
#}

Listing 4-1: 변수와 이 변수가 유효한 스코프

바꿔 말하면, 두가지 중요한 지점이 있습니다:

  1. 스코프 안에서 s가 등장하면, 유효합니다.
  2. 이 유효기간은 스코프 밖으로 벗어날 때까지 지속됩니다.

이 지점에서, 스코프와 변수가 유효한 시점 간의 관계는 다른 프로그래밍 언어와 비슷합니다. 이제 우리는 이에 대한 이해를 기초로 하여 String 타입을 소개함으로써 계속 쌓아나갈 것입니다.

String 타입

소유권 규칙을 설명하기 위하여, 우리는 3장에서 다룬 바 있는 타입보다 더 복잡한 데이터 타입이 필요합니다. 우리가 이전에 봐온 모든 데이터 타입들은 스택에 저장되었다가 스코프를 벗어날 때 스택으로부터 팝 됩니다만, 우리는 이제 힙에 저장되는 데이터를 관찰하고 러스트는 과연 어떻게 이 데이터를 비워내는지 설명할 필요가 있습니다.

우리는 여기서 String을 예제로 활용하되, 소유권과 관련된 String 내용의 일부분에 집중할 것입니다. 이러한 관점은 표준 라이브러리나 여러분들이 만들 다른 복잡한 데이터 타입에도 적용됩니다. String에 대해서는 8장에서 더 자세히 다루겠습니다.

스트링 리터럴을 이미 봤는데, 이 값은 프로그램 안에 하드코딩 되어 있습니다. 문자열 값은 편리하지만, 여러분이 텍스트를 필요로 하는 모든 경우에 대해 항상 적절하진 않습니다. 그 중 한가지 이유로, 문자열 값은 불변입니다(immutable). 또다른 이유는 모든 문자열이 우리가 프로그래밍 하는 시점에서 다 알수 있는 것이 아니란 점입니다: 예를 들면, 사용자의 입력을 받아 저장하고 싶다면요? 이러한 경우들에 대해서, 러스트는 두번째 문자열 타입인 String을 제공합니다. 이 타입은 힙에 할당되고 그런고로 컴파일 타임에는 우리가 알 수 없는 양의 텍스트를 저장할 수 있습니다. 여러분은 스트링 리터럴로부터 from이라는 함수를 이용해서 String을 아래처럼 만들 수 있습니다:


# #![allow(unused_variables)]
#fn main() {
let s = String::from("hello");
#}

더블 콜론(::)은 우리가 string_from과 같은 이름을 쓰기 보다는 String 타입 아래의 from 함수를 특정지을 수 있도록 해주는 네임스페이스 연산자입니다. 우리는 이러한 문법에 대해 5장의 "메소드 문법" 부분에서 더 자세히 다룰 것이고, 모듈에서의 네임스페이스와 관련한 이야기는 7장에서 할 것입니다.

이러한 종류의 문자열은 변경 가능합니다:


# #![allow(unused_variables)]
#fn main() {
let mut s = String::from("hello");

s.push_str(", world!"); // push_str()은 해당 스트링 리터럴을 스트링에 붙여줍니다.

println!("{}", s); // 이 부분이 `hello, world!`를 출력할 겁니다.
#}

그러니까, 여기서 어떤게 달라졌나요?, 왜 String은 변할 수 있는데 스트링 리터럴은 안될까요? 차이점은 두 타입이 메모리를 쓰는 방식에 있습니다.

메모리와 할당

스트링 리터럴의 경우, 우리는 내용물을 컴파일 타임에 알 수 있으므로 텍스트가 최종 실행파일에 직접 하드코딩 되었고, 이렇게 하면 스트링 리터럴이 빠르고 효율적이 됩니다. 그러나 이는 문자열이 변경되지 않는 것을 전재로 하는 특성입니다. 불행하게도, 우리는 컴파일 타임에 크기를 알 수 없는 경우 및 실행 중 크기가 변할 수도 있는 경우의 텍스트 조각을 바이너리 파일에 집어넣을 수 없습니다.

String 타입은 변경 가능하고 커질 수 있는 텍스트를 지원하기 위해 만들어졌고, 우리는 힙에서 컴파일 타임에는 알 수 없는 어느 정도 크기의 메모리 공간을 할당받아 내용물을 저장할 필요가 있습니다. 이는 즉 다음을 의미합니다:

  1. 런타임에 운영체제로부터 메모리가 요청되어야 한다.
  2. String의 사용이 끝났을 때 운영체제에게 메모리를 반납할 방법이 필요하다.

첫번째는 우리가 직접 수행합니다: 우리가 String::from을 호출하면, 구현부분에서 필요한 만큼의 메모리를 요청합니다. 이는 프로그래밍 언어들 사이에서 매우 일반적입니다.

하지만, 두번째는 다릅니다. 가비지 콜렉터(GC) 를 갖고 있는 언어들의 경우, GC가 더이상 사용하지 않는 메모리 조각을 계속해서 찾고 지워주며, 우리는 프로그래머로서 이와 관련한 생각을 안해도 됩니다. GC가 없을 경우, 할당받은 메모리가 더 필요없는 시점을 알아서 명시적으로 이를 반납하는 코드를 호출하는 것은 프로그래머의 책임입니다. 이를 올바르게 하는 것은 역사적으로 어려운 문제로 취급받았습니다. 우리가 잊어먹으면? 메모리를 낭비하는 것이죠. 너무 빨리 반납해버리면? 유효하지 않은 변수를 갖게 될 겁니다. 만일 반납을 두번하면? 이것도 버그죠. 우리는 딱 한번의 allocate와 한번의 free 쌍을 사용해야 합니다.

러스트는 다른 방식으로 이 문제를 다룹니다: 메모리는 변수가 소속되어 있는 스코프 밖으로 벗어나는 순간 자동으로 반납됩니다. 여기 스트링 리터럴 대신 String을 사용한 Listing 4-1의 스코프 예제가 있습니다:


# #![allow(unused_variables)]
#fn main() {
{
    let s = String::from("hello"); // s는 여기서부터 유효합니다

    // s를 가지고 뭔가 합니다
}                                  // 이 스코프는 끝났고, s는 더 이상 
                                   // 유효하지 않습니다
#}

String이 요구한 메모리를 운영체제에게 반납하는 자연스러운 지점이 있죠: s가 스코프 밖으로 벗어날 때입니다. 변수가 스코프 밖으로 벗어나면, 러스트는 우리를 위해 특별한 함수를 호출합니다. 이 함수를 drop이라고 부르고, String의 개발자가 메모리를 반환하도록 하는 코드를 집어넣을 수 있습니다. 러스트는 } 괄호가 닫힐때 자동적으로 drop을 호출합니다.

노트: C++에서는 이렇게 아이템의 수명주기의 끝나는 시점에 자원을 해제하는 패턴을 종종 자원 습득이 곧 초기화 (Resource Acquisition Is Initialization, RAII) 라고 부릅니다. 러스트의 drop 함수는 여러분이 RAII 패턴을 경험해본 적 있다면 익숙할 것입니다.

이 패턴은 러스트 코드가 작성되는 방법에 깊은 영향을 줍니다. 지금은 단순해 보이시겠지만, 우리가 힙에 할당시킨 데이터를 사용하는 여러 개의 변수를 사용하고자 할 경우와 같이 좀더 복잡한 상황에서, 코드의 동작은 예기치 못할 수 있습니다. 이제 그런 경우들을 좀더 탐험해봅시다.

변수와 데이터가 상호작용하는 방법: 이동(move)

여러 개의 변수들은 러스트에서 서로 다른 방식으로 같은 데이터에 대해 상호작용을 할 수 있습니다. Listing 4-2의 정수형을 이용한 예제를 한번 보겠습니다:


# #![allow(unused_variables)]
#fn main() {
let x = 5;
let y = x;
#}

Listing 4-2: 변수 x의 정수값을 y에 대입하기

우리는 아마도 다른 언어들에서의 경험을 토대로 어떤 일이 벌어지는지 추측할 수 있습니다: “정수값 5x에 묶어놓고; x의 값의 복사본을 만들어 y에 묶는다.” 우리는 이제 xy 두 개의 변수를 갖게 되었고, 둘 다 5와 같습니다. 정수값이 결정되어 있는 고정된 크기의 단순한 값이고, 5라는 값들이 스택에 푸쉬되기 때문에, 실제로도 이렇게 됩니다.

이제 String 버전을 봅시다:


# #![allow(unused_variables)]
#fn main() {
let s1 = String::from("hello");
let s2 = s1;
#}

이 코드는 이전의 코드와 매우 유사해 보여서, 동작하는 방식도 동일할 것이라고 가정할지도 모르겠습니다: 즉, 두번째 줄이 s1의 복사본을 만들어서 s2에 묶어놓는 식으로 말이죠. 그렇지만 이는 실제 동작과 다른 생각입니다.

좀 더 완전히 설명하기 위해, String이 Figure 4-3에서와 같이 생겼다는 것을 주목합시다. String은 그림의 왼쪽과 같이 세 개의 부분으로 이루어져 있습니다: 문자열의 내용물을 담고 있는 메모리의 포인터, 길이, 그리고 용량입니다. 이 데이터의 그룹은 스택에 저장됩니다. 내용물을 담은 오른쪽의 것은 힙 메모리에 있습니다.

String in memory

Figure 4-3: s1 변수에 "hello"값이 저장된 String의 메모리 구조

길이값은 바이트 단위로 String의 내용물이 얼마나 많은 메모리를 현재 사용하고 있는지를 말합니다. 용량값은 바이트 단위로 String이 운영체제로부터 얼마나 많은 양의 메모리를 할당 받았는지를 말합니다. 길이와 용량의 차이는 중요합니다만, 이번 내용에서는 아닙니다. 그러니까 현재로서는 용량값을 무시하셔도 좋겠습니다.

s2s1을 대입하면, String 데이터가 복사되는데, 이는 스택에 있는 포인터, 길이값, 그리고 용량값이 복사된다는 의미입니다. 포인터가 가리키고 있는 힙 메모리 상의 데이터는 복사되지 않습니다. 달리 말하면, 메모리 내의 데이터 구조는 Figure 4-4와 같이 됩니다.

s1 and s2 pointing to the same value

Figure 4-4: s1의 포인터, 길이값, 용량값이 복사된 s2 변수의 메모리 구조

메모리 구조는 Figure 4-5와 같지 않는데, 이 그림은 러스트가 힙 메모리 상의 데이터까지도 복사한다면 벌어질 일입니다. 만일 러스트가 이렇게 동작한다면, 힙 안의 데이터가 클 경우 s2 = s1 연산은 런타임 상에서 매우 느려질 가능성이 있습니다.

s1 and s2 to two places

Figure 4-5: 러스트가 힙 데이터까지 복사하게 될 경우 s2 = s1가 만들 또다른 가능성

앞서 우리는 변수가 스코프 밖으로 벗어날 때, 러스트는 자동적으로 drop함수를 호출하여 해당 변수가 사용하는 힙 메모리를 제거한다고 했습니다. 하지만 Figure 4-4에서는 두 데이터 포인터가 모두 같은 곳을 가리키고 있는 것이 보입니다. 이는 곧 문제가 됩니다: s2s1이 스코프 밖으로 벗어나게 되면, 둘 다 같은 메모리를 해제하려 할 것입니다. 이는 두번 해제 (double free) 오류라고 알려져 있으며 이전에 언급한 바 있는 메모리 안정성 버그들 중 하나입니다. 메모리를 두번 해제하는 것은 메모리 손상(memory corruption)의 원인이 되는데, 이는 보안 취약성 문제를 일으킬 가능성이 있습니다.

메모리 안정성을 보장하기 위해서, 러스트에서는 이런 경우 어떤 일이 일어나는지 한가지 더 디테일이 있습니다. 할당된 메모리를 복사하는 것을 시도하는 대신, 러스트에서는 s1이 더이상 유효하지 않다고 간주하고, 그러므로 러스트는 s1가 스코프 밖으로 벗어났을 때 이무것도 해제할 필요가 없어집니다. s1s2가 만들어진 후에 사용하려고 할때 어떤 일이 벌어지는지 확인해 봅시다:

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

println!("{}, world!", s1);

여러분은 아래와 같은 에러 메세지를 보게 될텐데, 그 이유는 러스트가 여러분으로부터 유효하지 않은 참조자를 사용하는 것을 막기 때문입니다:

error[E0382]: use of moved value: `s1`
 --> src/main.rs:4:27
  |
3 |     let s2 = s1;
  |         -- value moved here
4 |     println!("{}, world!", s1);
  |                            ^^ value used here after move
  |
  = note: move occurs because `s1` has type `std::string::String`,
which does not implement the `Copy` trait

만일 여러분이 다른 언어로 프로그래밍 하는 동안 “얕은 복사(shallow copy)”와 “깊은 복사(deep copy)”라는 용어를 들어보셨다면, 데이터의 복사 없이 포인터와 길이값 및 용량값만 복사한다는 개념이 얕은 복사와 비슷하게 들릴지도 모르겠습니다. 하지만 러스트는 첫번째 변수를 무효화 시키기도 하기 때문에, 이를 얕은 복사라고 부르는 대신 이동(move)이라 말합니다. 여기서 우리는 s1s2이동되었다라고 말하는 식으로 위 코드를 읽을 것입니다. 그러므로 실제로 일어낸 일은 Figure 4-6과 같습니다.

s1 moved to s2

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

이것이 우리 문제를 해결해줍니다! 오직 s2만 유효한 상황에서, 스코프 밖으로 벗어나면 혼자 메모리를 해제할 것이고, 일이 잘 처리되겠습니다.

여기에 더해서, 이러한 경우가 함축하는 디자인 선택이 있습니다: 러스트는 결코 자동적으로 여러분의 데이터에 대한 “깊은” 복사본을 만들지 않을 것입니다. 그러므로, 어떠한 자동적인 복사라도 런타임 실행 과정에서 효율적일 것이라 가정할 수 있습니다.

변수와 데이터가 상호작용하는 방법: 클론

만일 String의 스택 데이터 만이 아니라, 힙 데이터를 깊이 복사하기를 정말 원한다면, clone이라 불리우는 공용 메소드를 사용할 수 있습니다. 이 메소드 문법에 대해서는 5장에서 다루게 될 것입니다만, 이 메소드가 많은 프로그래밍 언어들 사이에서 흔한 특성이기 때문에, 여려분은 아마도 전에 이런 것들을 본적이 있을지도 모르겠습니다.

clone 메소드가 동작하는 예제를 보겠습니다:


# #![allow(unused_variables)]
#fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);
#}

이 코드는 잘 동작하고 Figure 4-5가 나타내는, 즉 힙 데이터가 정말로 복사되는 동작을 여러분이 명시적으로 만들어낼 수 있는 방법입니다.

clone을 호출하는 부분을 보면, 어떤 비용이 많이 들어갈지도 모르는 코드가 실행되는 중이란 것을 알 수 있게 됩니다. 이는 무언가 다른 동작이 수행되는 것을 알려주는 시각적인 지시자입니다.

스택에만 있는 데이터: 복사

우리가 아직 다루지 않은 또다른 부분이 있습니다. 아래 코드는 앞서 Listing 4-2에서 본 정수값을 이용하는 코드로, 잘 동작하며 유효합니다:


# #![allow(unused_variables)]
#fn main() {
let x = 5;
let y = x;

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

하지만 이 코드는 우리가 방금 배운 것과 대립되는 것처럼 보입니다: clone을 호출하지 않았지만, x도 유효하며 y로 이동하지도 않았지요.

그 이유는 정수형과 같이 컴파일 타임에 결정되어 있는 크기의 타입은 스택에 모두 저장되기 때문에, 실제 값의 복사본이 빠르게 만들어질 수 있습니다. 이는 변수 y가 생성된 후에 x가 더 이상 유효하지 않도록 해야할 이유가 없다는 뜻입니다. 바꿔 말하면, 여기서는 깊은 복사와 얕은 복사 간의 차이가 없다는 것으로, clone을 호출하는 것이 보통의 얕은 복사와 아무런 차이점이 없어 우리는 이를 그냥 버릴 수 있다는 것입니다.

러스트는 정수형과 같이 스택에 저장할 수 있는 타입에 대해 달수 있는 Copy 트레잇이라고 불리우는 특별한 어노테이션(annotation)을 가지고 있습니다 (트레잇에 관해서는 10장에서 더 자세히 보겠습니다). 만일 어떤 타입이 Copy 트레잇을 갖고 있다면, 대입 과정 후에도 예전 변수를 계속 사용할 수 있습니다. 러스트는 만일 그 타입 혹은 그 타입이 가지고 있는 부분 중에서 Drop 트레잇을 구현한 것이 있다면 Copy 트레잇을 어노테이션 할 수 없게끔 합니다. 만일 어떤 타입이 스코프 밖으로 벗어났을 때 어떤 특수한 동작을 필요로 하고 우리가 그 타입에 대해 Copy 어노테이션을 추가한다면, 컴파일 타임 오류를 보게 됩니다. Copy 어노테이션을 여러분의 타입에 어떤 식으로 추가하는지 알고 싶다면, 부록 C의 파생 가능한 트레잇(Derivable Traits)을 보세요.

그래서 어떤 타입이 Copy가 될까요? 여러분은 주어진 타입에 대해 확신을 하기 위해 문서를 확인할 수도 있겠지만, 일반적인 규칙으로서 단순한 스칼라 값들의 묶음은 Copy가 가능하고, 할당이 필요하거나 어떤 자원의 형태인 경우 Copy를 사용할 수 없습니다. Copy가 가능한 몇가지 타입을 나열해 보겠습니다:

  • u32와 같은 모든 정수형 타입들
  • truefalse값을 갖는 부울린 타입 bool
  • f64와 같은 모든 부동 소수점 타입들
  • Copy가 가능한 타입만으로 구성된 튜플들. (i32, i32)Copy가 되지만, (i32, String)은 안됩니다.

소유권과 함수

함수에게 값을 넘기는 의미론(semantics)은 값을 변수에 대입하는 것과 유사합니다. 함수에게 변수를 넘기는 것은 대입과 마찬가지로 이동하거나 복사될 것입니다. Listing 4-7은 변수가 스코프 안으로 들어갔다 밖으로 벗어나는 것을 주석과 함께 보여주는 예입니다:

Filename: src/main.rs

fn main() {
    let s = String::from("hello");  // s가 스코프 안으로 들어왔습니다.

    takes_ownership(s);             // s의 값이 함수 안으로 이동했습니다...
                                    // ... 그리고 이제 더이상 유효하지 않습니다.
    let x = 5;                      // x가 스코프 안으로 들어왔습니다.

    makes_copy(x);                  // x가 함수 안으로 이동했습니다만,
                                    // i32는 Copy가 되므로, x를 이후에 계속
                                    // 사용해도 됩니다.

} // 여기서 x는 스코프 밖으로 나가고, s도 그 후 나갑니다. 하지만 s는 이미 이동되었으므로,
  // 별다른 일이 발생하지 않습니다.

fn takes_ownership(some_string: String) { // some_string이 스코프 안으로 들어왔습니다.
    println!("{}", some_string);
} // 여기서 some_string이 스코프 밖으로 벗어났고 `drop`이 호출됩니다. 메모리는
  // 해제되었습니다.

fn makes_copy(some_integer: i32) { // some_integer이 스코프 안으로 들어왔습니다.
    println!("{}", some_integer);
} // 여기서 some_integer가 스코프 밖으로 벗어났습니다. 별다른 일은 발생하지 않습니다.

Listing 4-7: 소유권과 스코프에 대한 설명이 주석으로 달린 함수들

만일 우리가 stakes_ownership 함수를 호출한 이후에 사용하려 한다면, 러스트는 컴파일 타임 오류를 낼 것입니다. 이러한 정적 확인은 여러 실수들을 방지해 줍니다. 이후에 변수들을 사용할 수 있는지, 그리고 그러한 것을 소유권 규칙이 막아주는지를 확인해보려면 main 안에 sx에 관한 코드를 추가해보세요.

반환 값과 스코프

값의 반환 또한 소유권을 이동시킵니다. Listing 4-7과 비슷한 주석이 달린 예제를 하나 봅시다:

Filename: src/main.rs

fn main() {
    let s1 = gives_ownership();         // gives_ownership은 반환값을 s1에게
                                        // 이동시킵니다.

    let s2 = String::from("hello");     // s2가 스코프 안에 들어왔습니다.

    let s3 = takes_and_gives_back(s2);  // s2는 takes_and_gives_back 안으로
                                        // 이동되었고, 이 함수가 반환값을 s3으로도
                                        // 이동시켰습니다.

} // 여기서 s3는 스코프 밖으로 벗어났으며 drop이 호출됩니다. s2는 스코프 밖으로
  // 벗어났지만 이동되었으므로 아무 일도 일어나지 않습니다. s1은 스코프 밖으로
  // 벗어나서 drop이 호출됩니다.

fn gives_ownership() -> String {             // gives_ownership 함수가 반환 값을
                                             // 호출한 쪽으로 이동시킵니다.

    let some_string = String::from("hello"); // some_string이 스코프 안에 들어왔습니다.

    some_string                              // some_string이 반환되고, 호출한 쪽의
                                             // 함수로 이동됩니다.
}

// takes_and_gives_back 함수는 String을 하나 받아서 다른 하나를 반환합니다.
fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프
                                                      // 안으로 들어왔습니다.

    a_string  // a_string은 반환되고, 호출한 쪽의 함수로 이동됩니다.
}

변수의 소유권은 모든 순간 똑같은 패턴을 따릅니다: 어떤 값을 다른 변수에 대입하면 값이 이동됩니다. 힙에 데이터를 갖고 있는 변수가 스코프 밖으로 벗어나면, 해당 값은 데이터가 다른 변수에 의해 소유되도록 이동하지 않는한 drop에 의해 제거될 것입니다.

모든 함수가 소유권을 가졌다가 반납하는 것은 조금 지루해 보입니다. 만일 함수에게 값을 사용할 수 있도록 하되 소유권은 갖지 않도록 하고 싶다면요? 함수의 본체로부터 얻어진 결과와 더불어 우리가 넘겨주고자 하는 어떤 값을 다시 쓰고 싶어서 함께 반환받아야 한다면 꽤나 짜증나겠지요.

이게 아래와 같이 튜플을 이용하여 여러 값을 돌려받는 식으로 가능하긴 합니다:

Filename: src/main.rs

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)라고 부릅니다.

참조자(References)와 빌림(Borrowing)

앞 절의 마지막에 등장한 튜플을 이용하는 이슈는 String을 호출하는 함수 쪽으로 반환함으로써 calculate_length를 호출한 이후에도 여전히 String을 이용할 수 있도록 하는 것인데, 그 이유는 Stringcalculate_length 안쪽으로 이동되었기 때문입니다.

여기 값의 소유권을 넘기는 대신 개체에 대한 참조자(reference)를 인자로 사용하는 calculate_length 함수를 정의하고 이용하는 방법이 있습니다:

Filename: src/main.rs

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를 넘기고, 함수의 정의 부분에는 String이 아니라 &String을 이용했다는 점을 기억하세요.

이 엠퍼센드(&) 기호가 참조자이며, 이는 여러분이 어떤 값을 소유권을 넘기지 않고 참조할수 있도록 해줍니다. Figure 4-8은 이에 대한 다이어그램입니다.

&String s pointing at String s1

Figure 4-8: String s1을 가리키고 있는 &String s

함수 호출 부분을 좀더 자세히 봅시다:


# #![allow(unused_variables)]
#fn main() {
# fn calculate_length(s: &String) -> usize {
#     s.len()
# }
let s1 = String::from("hello");

let len = calculate_length(&s1);
#}

&s1 문법은 우리가 s1의 값을 참조하지만 소유하지는 않는 참조자를 생성하도록 해줍니다. 소유권을 갖고 있지는 않기 때문에, 이 참조자가 가리키는 값은 참조자가 스코프 밖으로 벗어났을 때도 메모리가 반납되지 않을 것입니다.

비슷한 이치로, 함수 시그니처도 &를 사용하여 인자 s의 타입이 참조자라는 것을 나타내고 있습니다. 설명을 위한 주석을 달아봅시다:


# #![allow(unused_variables)]
#fn main() {
fn calculate_length(s: &String) -> usize { // s는 String의 참조자입니다
    s.len()
} // 여기서 s는 스코프 밖으로 벗어났습니다. 하지만 가리키고 있는 값에 대한 소유권이 없기
  // 때문에, 아무런 일도 발생하지 않습니다.
#}

변수 s가 유효한 스코프는 여느 함수의 파라미터의 스코프와 동일하지만, 소유권을 갖고 있지 않으므로 이 참조자가 스코프 밖으로 벗어났을 때 참조자가 가리키고 있는 값은 버리지 않습니다. 또한 실제 값 대신 참조자를 파라미터로 갖고 있는 함수는 소유권을 갖고 있지 않기 때문에 소유권을 되돌려주기 위해 값을 다시 반환할 필요도 없다는 뜻이 됩니다.

함수의 파라미터로 참조자를 만드는 것을 빌림이라고 부릅니다. 실제 생활에서 만일 어떤 사람이 뭔가를 소유하고 있다면, 여러분은 그걸 빌릴 수 있습니다. 여러분의 용무가 끝났을 때는 그것을 돌려주어야 합니다.

그러니까 만일 우리가 빌린 무언가를 고치려고 시도한다면 무슨 일이 생길까요? Listing 4-9의 코드를 시험해보세요. 스포일러 경고: 작동이 안될겁니다!

Filename: src/main.rs

fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

Listing 4-9: 빌린 값을 고치려 해보기

여기 오류를 보시죠:

error: cannot borrow immutable borrowed content `*some_string` as mutable
 --> error.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^

변수가 기본적으로 불변인 것처럼, 참조자도 마찬가지입니다. 우리가 참조하는 어떤 것을 변경하는 것은 허용되지 않습니다.

가변 참조자(Mutable References)

Listing 4-9의 코드를 살짝만 바꾸면 오류를 고칠 수 있습니다:

Filename: src/main.rs

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

먼저 smut로 바꿔야 합니다. 그리고 &mut s로 가변 참조자를 생성하고 some_string: &mut String으로 이 가변 참조자를 받아야 합니다.

하지만 가변 참조자는 딱 한가지 큰 제한이 있습니다: 특정한 스코프 내에 특정한 데이터 조각에 대한 가변 참조자를 딱 하나만 만들 수 있다는 겁니다. 아래 코드는 실패할 겁니다:

Filename: src/main.rs

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

let r1 = &mut s;
let r2 = &mut s;

여기 오류를 보시죠:

error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> borrow_twice.rs:5:19
  |
4 |     let r1 = &mut s;
  |                   - first mutable borrow occurs here
5 |     let r2 = &mut s;
  |                   ^ second mutable borrow occurs here
6 | }
  | - first borrow ends here

이 제한 사항은 가변을 허용하긴 하지만 매우 통제된 형식으로 허용합니다. 이것이 새로운 러스트인들이 힘들어하는 부분인데, 대부분의 언어들은 여러분이 원하는대로 값을 변형하도록 해주기 때문입니다. 하지만 이러한 제한이 가지는 이점은 바로 러스트가 컴파일 타임에 데이터 레이스(data race)를 방지할 수 있도록 해준다는 것입니다.

데이터 레이스는 아래에 정리된 세 가지 동작이 발생했을때 나타나는 특정한 레이스 조건입니다:

  1. 두 개 이상의 포인터가 동시에 같은 데이터에 접근한다.
  2. 그 중 적어도 하나의 포인터가 데이터를 쓴다.
  3. 데이터에 접근하는데 동기화를 하는 어떠한 메커니즘도 없다.

데이터 레이스는 정의되지 않은 동작을 일으키고 런타임에 이를 추적하고자 할 때는 이를 진단하고 고치기 어려울 수 있습니다; 러스트는 데이터 레이스가 발생할 수 있는 코드가 컴파일 조차 안되기 때문에 이 문제의 발생을 막아버립니다!

항상 우리는 새로운 스코프를 만들기 위해 중괄호를 사용하는데, 이는 그저 동시에 만드는 것이 아니게 해줌으로써, 여러 개의 가변 참조자를 만들 수 있도록 해줍니다.


# #![allow(unused_variables)]
#fn main() {
let mut s = String::from("hello");

{
    let r1 = &mut s;

} // 여기서 r1은 스코프 밖으로 벗어났으므로, 우리는 아무 문제 없이 새로운 참조자를 만들 수 있습니다.

let r2 = &mut s;
#}

가변 참조자와 불변 참조자를 혼용할 경우에 대한 비슷한 규칙이 있습니다. 아래 코드는 컴파일 오류가 발생합니다:

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

let r1 = &s; // 문제 없음
let r2 = &s; // 문제 없음
let r3 = &mut s; // 큰 문제

여기 오류 메세지를 보시죠:

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as
immutable
 --> borrow_thrice.rs:6:19
  |
4 |     let r1 = &s; // 문제 없음
  |               - immutable borrow occurs here
5 |     let r2 = &s; // 문제 없음
6 |     let r3 = &mut s; // 큰 문제
  |                   ^ mutable borrow occurs here
7 | }
  | - immutable borrow ends here

어휴! 우리는 불변 참조자를 가지고 있을 동안에도 역시 가변 참조자를 만들 수 없습니다. 불변 참조자의 사용자는 사용중인 동안에 값이 값자기 바뀌리라 예상하지 않습니다! 하지만 여러 개의 불변 참조자는 만들 수 있는데, 데이터를 그냥 읽기만하는 것은 다른 것들이 그 데이터를 읽는데에 어떠한 영향도 주지 못하기 때문입니다.

때때로 이러한 오류들이 여러분을 좌절시킬지라도, 이것이 러스트 컴파일러가 (런타임이 아니라 컴파일 타임에) 일찌감치 잠재된 버그를 찾아내고, 왜 여러분의 데이터가 여러분 생각대로의 값을 갖고 있지 않은지 추적해 내려가는 대신 어느 지점이 문제인지를 정확히 보여주는 기능이란 점을 기억하세요.

댕글링 참조자(Dangling References)

포인터가 있는 언어에서는 자칫 잘못하면 댕글링 포인터(dangling pointer) 를 만들기 쉬운데, 댕글링 포인터란 어떤 메모리를 가리키는 포인터를 보존하는 동안, 그 메모리를 해제함으로써 다른 개체에게 사용하도록 줘버렸을 지도 모를 메모리를 참조하고 있는 포인터를 말합니다. 이와는 반대로, 러스트에서는 컴파일러가 모든 참조자들이 댕글링 참조자가 되지 않도록 보장해 줍니다: 만일 우리가 어떤 데이터의 참조자를 만들었다면, 컴파일러는 그 참조자가 스코프 밖으로 벗어나기 전에는 데이터가 스코프 밖으로 벗어나지 않을 것임을 확인해 줄 것입니다.

댕글링 참조자를 만드는 시도를 해봅시다:

Filename: src/main.rs

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

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

    &s
}

위 코드의 오류 메세지입니다:

error[E0106]: missing lifetime specifier
 --> dangle.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^^^^^^^
  |
  = help: this function's return type contains a borrowed value, but there is no
    value for it to be borrowed from
  = help: consider giving it a 'static lifetime

error: aborting due to previous error

이 오류 메세지는 우리가 아직 다루지 못한 특성을 인용하고 있습니다: 바로 라이프타임(lifetime) 입니다. 라이프타임에 대한 것은 10장에서 자세히 다룰 것입니다. 하지만 여러분이 라이프타임에 대한 부분을 무시한다면, 이 메세지는 이 코드가 왜 문제인지를 알려줄 열쇠를 쥐고 있습니다:

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from.
(해석: 이 함수의 반환 타입은 빌린 값을 포함하고 있는데, 빌려온 실제 값은 없습니다.)

dangle 코드 부분의 각 단계에서 어떤 일이 벌어지는지 더 면밀히 들여다봅시다:

fn dangle() -> &String { // dangle은 String의 참조자를 반환합니다

    let s = String::from("hello"); // s는 새로운 String입니다

    &s // 우리는 String s의 참조자를 반환합니다.
} // 여기서 s는 스코프를 벗어나고 버려집니다. 이것의 메모리는 사라집니다.
  // 위험하군요!

sdangle안에서 만들어졌기 때문에, dangle의 코드가 끝이나면 s는 할당 해제됩니다. 하지만 우리는 이것의 참조자를 반환하려고 했습니다. 이는 곧 이 참조자가 어떤 무효화된 String을 가리키게 될 것이란 뜻이 아닙니까! 별로 안 좋죠. 러스트는 우리가 이런 짓을 못하게 합니다.

여기서의 해법은 String을 직접 반환하는 것입니다:


# #![allow(unused_variables)]
#fn main() {
fn no_dangle() -> String {
    let s = String::from("hello");

    s
}
#}

이 코드는 아무런 문제없이 동작합니다. 소유권이 밖으로 이동되었고, 아무것도 할당 해제되지 않습니다.

참조자의 규칙

우리가 참조자에 대해 논의한 것들을 정리해 봅시다:

  1. 어떠한 경우이든 간에, 여러분은 아래 둘 다는 아니고 둘 중 하나만 가질 수 있습니다:
  • 하나의 가변 참조자
  • 임의 개수의 불변 참조자들
  1. 참조자는 항상 유효해야만 한다.

다음으로, 우리는 다른 종류의 참조자인 슬라이스(slice)를 볼 것입니다.

슬라이스(Slices)

소유권을 갖지 않는 또다른 데이터 타입은 슬라이스입니다. 슬라이스는 여러분이 컬렉션(collection) 전체가 아닌 컬렉션의 연속된 일련의 요소들을 참조할 수 있게 합니다.

여기 작은 프로그래밍 문제가 있습니다: 스트링을 입력 받아 그 스트링에서 찾은 첫번째 단어를 반환하는 함수를 작성하세요. 만일 함수가 공백문자를 찾지 못한다면, 이는 전체 스트링이 한 단어라는 의미이고, 이때는 전체 스트링이 반환되어야 합니다.

이 함수의 시그니처(signature)에 대해 생각해봅시다:

fn first_word(s: &String) -> ?

이 함수 first_word&String을 파라미터로 갖습니다. 우리는 소유권을 원하지 않으므로, 이렇게 해도 좋습니다. 하지만 뭘 반환해야할까요? 우리는 스트링의 일부에 대해 표현할 방법이 없습니다. 하지만 단어의 끝부분의 인덱스를 반환할 수는 있겠습니다. Listing 4-10의 코드처럼 시도해 봅시다:

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
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()
}
#}

Listing 4-10: String 파라미터의 바이트 인덱스 값을 반환하는 first_word 함수

이 코드를 쪼개서 봅시다. 입력된 String를 요소별로 보면서 그 값이 공백인지 확인할 필요가 있기 때문에, Stringas_bytes 메소드를 이용하여 바이트 배열로 변환됩니다:

let bytes = s.as_bytes();

다음으로, iter 메소드를 이용하여 바이트 배열의 반복자(iterator)를 생성합니다:

for (i, &item) in bytes.iter().enumerate() {

반복자에 대한 것은 13장에서 더 자세히 다루겠습니다. 지금은 iter가 컬렉션의 각 요소를 반환하는 함수이며, enumerateiter의 결과값을 직접 반환하는 대신 이를 감싸서 튜플의 일부로 만들어 반환한다는 정도만 알아두세요. 반환된 튜플의 첫번째 요소는 인덱스이며, 두번째 요소는 요소에 대한 참조값입니다. 이는 우리 스스로 인덱스를 계산하는 것보다 조금 더 편리합니다.

enumerate 메소드가 튜플을 반환하기 때문에, 우리는 러스트의 다른 모든 부분에서 그러하듯이 이 튜플을 해체하기 위해 패턴을 이용할 수 있습니다. 따라서 for 루프 내에서, i는 튜플 내의 인덱스에 대응하고 &item은 튜플 내의 한 바이트에 대응하는 패턴을 기술한 것입니다. .iter().enumerate()의 요소에 대한 참조자를 갖는 것이므로, &을 패턴 내에 사용했습니다.

우리는 바이트 리터럴 문법을 이용하여 공백 문자를 나타내는 바이트를 찾습니다. 공백 문자를 찾았다면, 이 위치를 반환합니다. 그렇지 않으면 s.len()을 통해 스트링의 길이값을 반환합니다:

    if item == b' ' {
        return i;
    }
}
s.len()

이제 우리에게 스트링의 첫번째 단어의 끝부분의 인덱스를 찾아낼 방법이 생겼습니다. usize를 그대로 반환하고 있지만, 이는 &string의 내용물 내에서만 의미가 있습니다. 바꿔 말하면, 이것이 String로부터 분리되어 있는 숫자이기 때문에, 이것이 나중에도 여전히 유효한지를 보장할 길이 없습니다. Listing 4-10의 first_word 함수를 사용하는 Listing 4-11의 프로그램을 보시죠:

Filename: src/main.rs

# 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()
# }
#
fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word는 5를 갖게 될 것입니다.

    s.clear(); // 이 코드는 String을 비워서 ""로 만들게 됩니다.

    // word는 여기서 여전히 5를 갖고 있지만, 5라는 값을 의미있게 쓸 수 있는 스트링은 이제 없습니다.
    // word는 이제 완전 유효하지 않습니다!
}

Listing 4-11: first_word 함수를 호출하여 결과를 저장한 뒤 String의 내용물을 바꾸기

이 프로그램은 아무런 오류 없이 컴파일되고, s.clear()을 호출한 뒤 word를 사용한다 해도 역시 컴파일될 것입니다. words의 상태와 전혀 연결되어 있지 않으므로, word는 여전히 값 5를 담고 있습니다. 우리는 첫번째 단어를 추출하고자 하기 위해 s와 값 5를 사용할 수 있지만, word5를 저장한 뒤 s의 내용물이 변경되었기 때문에 이러한 사용은 버그가 될 것입니다.

word의 인덱스가 s의 데이터와 싱크가 안맞을 것을 걱정하는 건 지겹고 쉽게 발생할 수 있는 오류입니다! 이러한 인덱스들을 관리하는 것은 우리가 second_word 함수를 작성했을 때 더더욱 다루기 어려워집니다. 이 함수의 시그니처는 아래와 같은 모양이 되어야 할 것입니다:

fn second_word(s: &String) -> (usize, usize) {

이제 우리는 시작, 그리고 끝 인덱스를 추적하고 있고, 특정 상태에 있는 데이터로부터 계산되었지만 그 상태와 전혀 묶여있지 않은 더 많은 값들을 갖게 됩니다. 이제 우리는 동기화를 유지할 필요가 있는 주위를 떠다니는 세 개의 관련없는 변수들을 갖게 되었습니다.

운좋게도, 러스트는 이러한 문제에 대한 해결책을 갖고 있습니다: 바로 스트링 슬라이스(string slice) 입니다.

스트링 슬라이스

스트링 슬라이스String의 일부분에 대한 참조자고, 아래와 같이 생겼습니다:


# #![allow(unused_variables)]
#fn main() {
let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];
#}

이는 전체 String의 참조자를 갖는 것과 비슷하지만, 추가적으로 [0..5]라는 코드가 붙어 있습니다. 전체 String에 대한 참조자 보다는, String의 일부분에 대한 참조자입니다. start..end 문법은 start부터 시작하여 end를 포함하지 않는 연속된 범위를 기술합니다.

우리는 대괄호 내에 [starting_index..ending_index]를 특정한 범위를 이용하여 슬라이스를 만들 수 있는데, 여기서 starting_index는 슬라이스에 포함되는 첫번째 위치이고 ending_index는 슬라이스에 포함될 마지막 위치보다 1을 더한 값입니다. 내부적으로 슬라이스 데이터 구조는 시작 위치와 슬라이스의 길이를 저장하는데, 이 길이 갚은 ending_index에서 starting_index를 뺀 값입니다. 따라서 let world = &[6..11];의 경우, worlds의 6번째 바이트를 가리키고 있는 포인터와 길이값 5를 갖고 있는 슬라이스가 될 것입니다.

Figure 4-12는 이를 다이어그램으로 보여줍니다.

world containing a pointer to the 6th byte of String s and a length 5

Figure 4-12: String의 일부를 참조하는 스트링 슬라이스

러스트의 .. 범위 문법을 사용하여, 여러분이 만일 첫번째 인덱스(즉 0)에서부터 시작하길 원한다면, 두 개의 마침표 전의 값은 생략할 수 있습니다. 다시 말하면, 아래의 두 줄은 동일한 표현입니다:


# #![allow(unused_variables)]
#fn main() {
let s = String::from("hello");

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

비슷한 이치로, 만일 여러분의 슬라이스가 String의 마지막 바이트까지 포함한다면, 여러분은 끝의 숫자를 생략할 수 있습니다. 이는 아래 두 줄의 표현이 동일하다는 의미입니다:


# #![allow(unused_variables)]
#fn main() {
let s = String::from("hello");

let len = s.len();

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

여러분은 또한 전체 스트링의 슬라이스를 만들기 위해 양쪽 값을 모두 생략할 수 있습니다. 따라서 아래 두 줄의 표현은 동일합니다:


# #![allow(unused_variables)]
#fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
#}

이 모든 정보를 잘 기억하시고, first_word가 슬라이스를 반환하도록 다시 작성해봅시다. “스트링 슬라이스”를 나타내는 타입은 &str로 씁니다:

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
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[..]
}
#}

우리는 Listing 4-10에서 작성한 것과 같은 방법으로 공백 문자가 첫번째로 나타난 지점을 찾아서 단어의 끝 인덱스를 얻어냅니다. 공백 문자를 찾으면, 스트링의 시작과 공백 문자의 인덱스를 각각 시작과 끝 인덱스로 사용하여 스트링 슬라이스를 반환합니다.

이제 first_word가 호출되면, 해당 데이터와 묶여있는 하나의 값을 반환받게 되었습니다. 이 값은 슬라이스의 시작 위치에 대한 참조자와 슬라이스의 요소 개수로 이루어져 있습니다.

second_word 함수에 대해서도 마찬가지로 슬라이스를 반환하는 형식이 잘 동작할 것입니다:

fn second_word(s: &String) -> &str {

우리는 이제 엉망이 되기 훨씬 힘든 직관적인 API를 갖게 되었는데, 이는 컴파일러가 String에 대한 참조자들이 유효한 상태로 남아있게끔 보장할 것이기 때문입니다. 첫번째 단어의 끝 인덱스를 찾았지만, 그 후 스트링을 비워버려서 인덱스가 유효하지 않게되는 Listing 4-11의 프로그램 내의 버그를 기억하시나요? 그런 코드는 논리적으로 맞지 않지만 어떠한 즉각적인 오류도 보여주지 못합니다. 그런 문제는 우리가 비어 있는 스트링에 대해 첫번째 단어의 인덱스를 사용하고자 시도할 경우에나 나타나게 될 것입니다. 슬라이스는 이러한 버그를 불가능하게 만들고 우리가 코드 내에서 발생할 수 있는 문제를 훨씬 일찍 알게 해줍니다. first_word의 슬라이스 버젼을 이용하는 것은 컴파일 타임 오류를 발생시킬 것입니다:

Filename: src/main.rs

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // Error!
}

여기 컴파일 오류 메세지를 보시죠:

17:6 error: cannot borrow `s` as mutable because it is also borrowed as
            immutable [E0502]
    s.clear(); // Error!
    ^
15:29 note: previous borrow of `s` occurs here; the immutable borrow prevents
            subsequent moves or mutable borrows of `s` until the borrow ends
    let word = first_word(&s);
                           ^
18:2 note: previous borrow ends here
fn main() {

}
^

빌림 규칙에서 우리가 만일 무언가에 대한 불변 참조자를 만들었을 경우, 가변 참조자를 만들 수 없다는 점을 상기해보세요. clear 함수가 String을 잘라낼 필요가 있기 때문에, 이 함수는 가변 참조자를 갖기 위한 시도를 할 것이고, 이는 실패하게 됩니다. 러스트는 우리의 API를 사용하기 쉽게 해줄 뿐만 아니라 이러한 종류의 오류 전체를 컴파일 타임에 제거해 줍니다!

스트링 리터럴은 슬라이스입니다

스트링 리터럴이 바이너리 안에 저장된다고 하는 얘기를 상기해봅시다. 이제 슬라이스에 대해 알았으니, 우리는 스트링 리터럴을 적합하게 이해할 수 있습니다:


# #![allow(unused_variables)]
#fn main() {
let s = "Hello, world!";
#}

여기서 s의 타입은 &str입니다: 이것은 바이너리의 특정 지점을 가리키고 있는 슬라이스입니다. 이는 왜 스트링 리터럴이 불변인가도 설명해줍니다; &str은 불변 참조자이기 때문입니다.

파라미터로서의 스트링 슬라이스

여러분이 리터럴과 String의 슬라이스를 얻을 수 있다는 것을 알게 되었다면 first_word 함수를 한번 더 개선시킬 수 있는데, 바로 이 함수의 시그니처입니다:

fn first_word(s: &String) -> &str {

더 경험이 많은 러스트인이라면 대신 아래와 같이 작성하는데, 그 이유는 String&str 둘 모두에 대한 같은 함수를 사용할 수 있도록 해주기 때문입니다.

fn first_word(s: &str) -> &str {

만일 우리가 스트링 슬라이스를 갖고 있다면, 이를 바로 넘길 수 있습니다. String을 갖고 있다면, 이 String의 전체 슬라이스를 넘길 수 있습니다. 함수가 String의 참조자 대신 스트링 슬라이스를 갖도록 정의하는 것은 우리의 API를 어떠한 기능적인 손실 없이도 더 일반적이고 유용하게 해줍니다:

Filename: src/main.rs

# fn first_word(s: &str) -> &str {
#     let bytes = s.as_bytes();
#
#     for (i, &item) in bytes.iter().enumerate() {
#         if item == b' ' {
#             return &s[0..i];
#         }
#     }
#
#     &s[..]
# }
fn main() {
    let my_string = String::from("hello world");

    // first_word가 `String`의 슬라이스로 동작합니다.
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word가 스트링 리터럴의 슬라이스로 동작합니다.
    let word = first_word(&my_string_literal[..]);

    // 스트링 리터럴은 *또한* 스트링 슬라이스이기 때문에,
    // 아래 코드도 슬라이스 문법 없이 동작합니다!
    let word = first_word(my_string_literal);
}

그 밖의 슬라이스들

스트링 슬라이스는 여러분이 상상하는 바와 같이, 스트링에 특정되어 있습니다. 하지만 더 일반적인 슬라이스 타입도 역시 있습니다. 아래 배열을 보시죠:


# #![allow(unused_variables)]
#fn main() {
let a = [1, 2, 3, 4, 5];
#}

우리가 스트링의 일부를 참조하고 싶어할 수 있는 것처럼, 배열의 일부를 참조하고 싶을 수 있고, 그러면 아래와 같이 쓸 수 있습니다:


# #![allow(unused_variables)]
#fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];
#}

이 슬라이스는 &[i32] 타입을 갖습니다. 이는 스트링 슬라이스가 동작하는 방법과 똑같이, 슬라이스의 첫번째 요소에 대한 참조자와 슬라이스의 길이를 저장하는 방식으로 동작합니다. 여러분은 다른 모든 종류의 컬렉션들에 대하여 이런 종류의 슬라이스를 이용할 수 있습니다. 벡터에 대해서 8장에서 이야기할 때 이러한 컬렉션에 대해 더 자세히 다루겠습니다.

정리

소유권, 빌림, 그리고 슬라이스의 개념은 러스트 프로그램의 메모리 안정성을 컴파일 타임에 보장하는 것입니다. 러스트 언어는 다른 시스템 프로그래밍 언어와 같이 여러분의 메모리 사용에 대한 제어권을 주지만, 데이터의 소유자가 스코프 밖으로 벗어났을 때 소유자가 자동적으로 데이터를 버리도록 하는 것은 곧 여러분이 이러한 제어를 위해 추가적인 코드 작성이나 디버깅을 하지 않아도 된다는 뜻입니다.

소유권은 러스트의 다른 수많은 부분이 어떻게 동작하는지에 영향을 주므로, 이 책의 남은 부분 전체에 걸쳐 이 개념들에 대해 더 이야기할 것입니다. 다음 장으로 넘어가서 데이터들을 함께 그룹짓는 struct를 보겠습니다.

연관된 데이터들을 구조체로 다루기

구조체(struct)는 사용자들이 연관된 여러 값들을 묶어서 의미있는 데이터 단위를 정의할 수 있게 합니다. 객체지향 언어를 사용해 본 경험이 있으시다면, 구조체(struct)는 객체의 데이터 속성 같은 것으로 보시면 됩니다. 이번 장에서는 튜플과 구조체를 비교해 보고, 구조체를 어떻게 사용하는지 알아보며, 메소드와 구조체 데이터의 동작과 관련된 연관함수(associated functions)의 정의 방법에 대해 알아보도록 하겠습니다. 구조체와 열거형(6장에서 살펴볼 것입니다)에 대한 개념은 여러분의 프로그램 도메인 상에서 새로운 타입을 만들기 위한 기초 재료로서, 러스트의 컴파일 시점 타입 검사 기능을 최대한 활용합니다.

구조체를 정의하고 초기화하기

구조체는 3장에서 학습한 튜플과 비슷합니다. 튜플과 유사하게, 구조체의 구성요소들은 각자 다른 타입을 지닐 수 있습니다. 그러나 튜플과는 다르게 각 구성요소들은 명명할 수 있어 값이 의미하는 바를 명확하게 인지할 수 있습니다. 구조체는 각 구성요소들에 명명을 할 수 있다는 점 덕분에 튜플보다 유연하게 다룰 수 있습니다. 구조체 내의 특정 요소 데이터 명세를 기술하거나, 접근할 때 순서에 의존할 필요가 없기 때문입니다.

구조체를 정의할 때는 struct 키워드를 먼저 입력하고 명명할 구조체명을 입력하면 됩니다. 구조체의 이름은 함께 묶이게 되는 구성요소들의 의미를 내포할 수 있도록 짓는 것이 좋습니다. 이후 중괄호 안에서는, 필드(field)라 불리는 각 구성요소들의 타입과 접근할 수 있는 이름을 정의합니다.

아래 예제 5-1에서는 사용자 계정에 대한 정보를 저장하는 구조체를 정의합니다.


# #![allow(unused_variables)]
#fn main() {
struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}
#}

Listing 5-1: 사용자 계정정보를 저장하는 User 구조체 정의

정의한 구조체를 사용하려면, 각 필드의 값을 명세한 인스턴스(instance)를 생성해야 합니다. 인스턴스는 구조체의 이름을 명시함으로써 사용할 수 있고, 필드를 식별할 수 있는 이름인 키와 그 키에 저장하고자 하는 값의 쌍(key:value)을 이어지는 중괄호 안에 추가하여 생성할 수 있습니다.

구조체를 정의할때 필드들의 순서가 정의한 필드의 순서와 같을 필요는 없습니다. 달리 서술하자면, 구조체 정의는 무엇이 들어가야 하는 지 대략적으로 정의된 양식 정도라고 생각하시면 되고, 인스턴스는 그것에 특정한 값을 넣어 실체화한 것이라 생각하시면 됩니다. 아래 예제 5-2에서는 특정 사용자를 선언하는 과정을 보여줍니다.


# #![allow(unused_variables)]
#fn main() {
# struct User {
#     username: String,
#     email: String,
#     sign_in_count: u64,
#     active: bool,
# }
#
let user1 = User {
    email: String::from("someone@example.com"),
    username: String::from("someusername123"),
    active: true,
    sign_in_count: 1,
};
#}

Listing 5-2: 구조체 User의 인스턴스 생성하기

구조체에서 특정한 값을 읽어오려면, 점(.) 표기법을 사용하시면 됩니다. 사용자의 이메일 값을 얻고자 하면, user1.email 과 같은 방식으로 접근하실 수 있습니다. 변경이 가능한 구조체에 들어있는 값을 바꾸고자 할 때는, 아래와 같이 점(.) 표기법을 사용하여 새 값을 할당할 수 있습니다. user1.email = String::from("someone-else@example.com");

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

변수명과 구조체의 필드명이 같다면, 필드 초기화 축약법(field init shorthand) 을 이용할 수 있습니다. 이를 활용하면 구조체를 생성하는 함수를 더 간단히 작성할 수 있게 됩니다. 아래 예제 5-3의 build_user 함수에는 emailusername 라는 매개변수가 있습니다. 함수는 User구조체가 구현된 인스턴스를 반환합니다.


# #![allow(unused_variables)]
#fn main() {
# struct User {
#     username: String,
#     email: String,
#     sign_in_count: u64,
#     active: bool,
# }
#
fn build_user(email: String, username: String) -> User {
    User {
        email: email,
        username: username,
        active: true,
        sign_in_count: 1,
    }
}
#}

예제 5-3: 사용자의 이메일과 이름을 받아 User구조체의 인스턴스를 반환하는 build_user 함수

매개변수인 emailusernameUser구조체의 필드명과 같기 떄문에, 함수 build_user 에서 emailusername를 명시하는 부분을 예제 5-4와 같이 다시 작성할 필요가 없습니다.

예제 5-4의 build_user 함수는 예제 5-3과 같은 방식으로 동작합니다. 필드 초기화를 이러한 방식으로 수행하는 문법은 간결한 코드를 작성하는데 도움이 되고, 많은 필드의 값이 정의되어야할 때 특히 유용합니다.


# #![allow(unused_variables)]
#fn main() {
# struct User {
#     username: String,
#     email: String,
#     sign_in_count: u64,
#     active: bool,
# }
#
fn build_user(email: String, username: String) -> User {
    User {
        email,
        username,
        active: true,
        sign_in_count: 1,
    }
}
#}

예제 5-4: 매개변수 emailusername가 구조체의 필드와 이름이 같아, 함수 내에서 특별히 명시하지 않고 초기화한 예인 build_user 함수

구조체 갱신법을 이용하여 기존 구조체 인스턴스로 새 구조체 인스턴스 생성하기

존재하는 인스턴스에서 기존 값의 대부분은 재사용하고, 몇몇 값만 바꿔 새로운 인스턴스를 정의하는 방법은 유용합니다. 예제 5-5는 변수 user2emailusername은 새로 할당하고, 나머지 필드들은 예제 5-2에서 정의한 user1의 값들을 그대로 사용하는 방식으로 User 인스턴스를 생성하는 것을 보여줍니다.


# #![allow(unused_variables)]
#fn main() {
# struct User {
#     username: String,
#     email: String,
#     sign_in_count: u64,
#     active: bool,
# }
#
# let user1 = User {
#     email: String::from("someone@example.com"),
#     username: String::from("someusername123"),
#     active: true,
#     sign_in_count: 1,
# };
#
let user2 = User {
    email: String::from("another@example.com"),
    username: String::from("anotherusername567"),
    active: user1.active,
    sign_in_count: user1.sign_in_count,
};
#}

예제 5-5: user1을 일부 값들을 재사용하여, 구조체 User의 인스턴스 user2를 새로 생성

구조체 갱신법(struct update syntax)은 예제 5-5에서 작성한 짧은 코드와 같은 효과를 낼 수 있습니다. 구조체 갱신법은, 입력으로 주어진 인스턴스와 변화하지 않는 필드들을 명시적으로 할당하지 않기 위해 .. 구문을 사용합니다. 예제 5-6의 코드는 user1 인스턴스와 active, sign_in_count 필드의 값은 같고, emailusername 필드들은 값은 다른 user2 인스턴스를 생성할 때 구조체 갱신법을 사용하는 것을 보여줍니다.


# #![allow(unused_variables)]
#fn main() {
# struct User {
#     username: String,
#     email: String,
#     sign_in_count: u64,
#     active: bool,
# }
#
# let user1 = User {
#     email: String::from("someone@example.com"),
#     username: String::from("someusername123"),
#     active: true,
#     sign_in_count: 1,
# };
#
let user2 = User {
    email: String::from("another@example.com"),
    username: String::from("anotherusername567"),
    ..user1
};
#}

예제 5-6: 인스턴스 갱신 문법의 사용 예시 - 새 User 구조체 생성 시 emailusername 필드에는 새 값을 할당하고, 나머지 필드는 user1에서 재사용

이름이 없고 필드마다 타입은 다르게 정의 가능한 튜플 구조체

구조체명을 통해 의미를 부여할 수 있으나 필드의 타입만 정의할 수 있고 명명은 할 수 없는, 튜플 구조체(tuple structs)라 불리는 튜플과 유사한 형태의 구조체도 정의할 수 있습니다.

튜플 구조체는 일반적인 구조체 정의방법과 똑같이 struct 키워드를 통해 정의할 수 있고, 튜플의 타입 정의가 키워드 뒤에서 이루어지면 됩니다. 아래는 튜플 구조체인 Color, Point의 정의와 사용 예시 입니다.


# #![allow(unused_variables)]
#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이 다른 타입이란 것을 유념해 두셔야 합니다. 구조체 내의 타입이 모두 동일하더라도 각각의 구조체는 고유의 타입이기 때문입니다. 한편 튜플 구조체 인스턴스는, 3장에서 살펴 본 튜플과 비슷하게 동작합니다.

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

또한 어떤 필드도 없는 구조체 역시 정의할 수 있습니다! 이는 유닛 타입인 ()와 비슷하게 동작하고, 그 때문에 유사 유닛 구조체(unit-like structs)라 불립니다. 유사 유닛 구조체는 특정한 타입의 트레잇(trait)을 구현해야하지만 타입 자체에 데이터를 저장하지 않는 경우에 유용합니다. 트레잇(trait)에 대해서는 10장에서 더 살펴보도록 하겠습니다.

구조체 데이터의 소유권(Ownership)

예제 5-1에서의 User 구조체 정의에서는, &str 문자 슬라이스 타입 대신 String타입을 사용했습니다. 이는 의도적인 선택으로, 구조체 전체가 유효한 동안 구조체가 그 데이터를 소유하게 하고자 함입니다.

구조체가 소유권이 없는 데이터의 참조를 저장할수는 있지만, 10장에서 언급 될 라이프타임(lifetimes) 의 사용을 전제로 합니다. 라이프타임은 구조체가 존재하는동안 참조하는 데이터를 계속 존재할 수 있도록 합니다. 라이프타임을 사용하지 않고 참조를 저장하고자 하면 아래와 같은 일이 발생합니다.

Filename: src/main.rs

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

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

컴파일러는 라이프타임이 명시되어야 한다고 에러를 발생시킵니다.

error[E0106]: missing lifetime specifier
 -->
  |
2 |     username: &str,
  |               ^ expected lifetime parameter

error[E0106]: missing lifetime specifier
 -->
  |
3 |     email: &str,
  |            ^ expected lifetime parameter

참조가 저장이 불가능한 위 에러 개선에 대해서는 10장에서 살펴보도록 하겠습니다. 지금은 &str 대신 String 을 사용하는 방식으로 에러를 고치도록 하겠습니다.

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

어느 시점에 구조체를 이용하기를 원하게 될지를 이해해보기 위해서, 사각형의 넓이를 계산하는 프로그램을 작성해봅시다. 단일 변수들로 구성된 프로그램으로 시작한 뒤, 이 대신 구조체를 이용하기까지 프로그램을 리팩토링해 볼 것입니다.

Cargo로 픽셀 단위로 명시된 사각형의 길이와 너비를 입력받아서 사각형의 넓이를 계산하는 rectangles라는 이름의 새로운 바이너리 프로젝트를 만듭시다. Listing 5-7은 우리 프로젝트의 src/main.rs 내에 설명한 동작을 수행하는 한 방법을 담은 짧은 프로그램을 보여줍니다:

Filename: src/main.rs

fn main() {
    let length1 = 50;
    let width1 = 30;

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

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

Listing 5-7: 길이와 너비가 각각의 변수에 지정된 사각형의 넓이 계산하기

이제 이 프로그램을 cargo run으로 실행해보세요:

The area of the rectangle is 1500 square pixels.

튜플을 이용한 리팩터링

비록 Listing 5-7가 잘 동작하고 각 차원축의 값을 넣은 area 함수를 호출함으로써 사각형의 넓이를 알아냈을지라도, 이것보다 더 좋게 할 수 있습니다. 길이와 너비는 함께 하나의 사각형을 기술하기 떄문에 서로 연관되어 있습니다.

이 방법에 대한 사안은 area의 시그니처에서 여실히 나타납니다:

fn area(length: u32, width: u32) -> u32 {

area 함수는 어떤 사각형의 넓이를 계산하기로 되어있는데, 우리가 작성한 함수는 두 개의 파라미터들을 가지고 있습니다. 파라미터들은 연관되어 있지만, 우리 프로그램 내의 어디에도 표현된 바 없습니다. 길이와 너비를 함께 묶는다면 더 읽기 쉽고 관리하기도 좋을 것입니다. 페이지 XX, 3장의 튜플로 값들을 묶기 절에서 이런 일을 하는 한가지 방법을 이미 다루었습니다: 바로 튜플을 이용하는 것이지요. Listing 5-8은 튜플을 이용한 우리 프로그램의 또다른 버전을 보여줍니다:

Filename: src/main.rs

fn main() {
    let rect1 = (50, 30);

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

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

Listing 5-8: 튜플을 이용하여 사각형의 길이와 너비를 명시하기

어떤 면에서는 프로그램이 더 좋아졌습니다. 튜플은 한 조각의 구조체를 추가할 수 있게 해주고, 우리는 이제 단 하나의 인자만 넘기게 되었습니다. 그러나 다른 한편으로 이 버전은 덜 명확합니다: 튜플은 요소에 대한 이름이 없어서, 튜플 내의 값을 인덱스로 접근해야 하기 때문에 우리의 계산이 더 혼란스러워 졌습니다.

면적 계산에 대해서는 길이와 너비를 혼동하는 것이 큰 문제가 아니겠으나, 만일 우리가 화면에 이 사각형을 그리고 싶다면, 문제가 됩니다! 우리는 length가 튜플 인덱스 0이고 width가 튜플 인덱스 1이라는 점을 꼭 기억해야 할 것입니다. 만일 다른 누군가가 이 코드를 이용해서 작업한다면, 그들 또한 이 사실을 알아내어 기억해야 할테지요. 우리의 코드 내에 데이터의 의미를 전달하지 않았기 때문에, 이 값들을 잊어먹거나 혼동하여 에러를 발생시키는 일이 쉽게 발생할 것입니다.

구조체를 이용한 리팩터링: 의미를 더 추가하기

우리는 데이터에 이름표를 붙여 의미를 부여하기 위해 구조체를 이용합니다. Listing 5-9에서 보시는 바와 같이, 우리가 사용중인 튜플은 전체를 위한 이름 뿐만 아니라 부분들을 위한 이름들도 가지고 있는 데이터 타입으로 변형될 수 있습니다:

Filename: src/main.rs

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

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

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

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

Listing 5-9: Rectangle 구조체 정의하기

여기서 우리는 구조체를 정의하고 이를 Rectangle이라 명명했습니다. {} 안에서 lengthwidth를 필드로 정의했는데, 둘 모두 u32 타입입니다. 그런 다음 main 함수 안에서 길이 50 및 너비 30인 특정한 Rectangle 인스턴스(instance)를 생성했습니다.

우리의 area 함수는 이제 하나의 파라미터를 갖도록 정의되었는데, 이는 rectangle이라는 이름이고, Rectangle 구조체 인스턴스의 불변 참조자 타입입니다. 4장에서 언급했듯이, 우리는 구조체의 소유권을 얻기 보다는 빌리기를 원합니다. 이 방법으로, main은 그 소유권을 유지하고 rect1을 계속 이용할 수 있는데, 이는 우리가 함수 시그니처 내에서와 함수 호출시에 &를 사용하게 된 이유입니다.

area 함수는 Rectangle 인스턴스 내의 lengthwidth 필드에 접근합니다. area에 대한 우리의 함수 시그니처는 이제 정확히 우리가 의미한 바를 나타냅니다: lengthwidth 필드를 사용하여 Rectangle의 넓이를 계산한다는 뜻 말이죠. 이는 길이와 너비가 서로 연관되어 있음을 잘 전달하며, 01을 사용한 튜플 인덱스 값을 이용하는 대신에 값들에 대해서 서술적인 이름을 사용합니다 - 명확성 측면에서 승리입니다.

파생 트레잇(derived trait)으로 유용한 기능 추가하기

우리가 프로그램을 디버깅하는 동안 구조체 내의 모든 값을 보기 위해서 Rectangle의 인스턴스를 출력할 수 있다면 도움이 될 것입니다. Listing 5-10은 우리가 이전 장들에서 해왔던 것처럼 println! 매크로를 이용한 것입니다:

Filename: src/main.rs

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

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

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

Listing 5-10: Rectangle 인스턴스 출력 시도하기

이 코드를 실행시키면, 다음과 같은 핵심 메세지와 함께 에러가 발생합니다:

error[E0277]: the trait bound `Rectangle: std::fmt::Display` is not satisfied

println! 매크로는 다양한 종류의 포맷을 출력할 수 있으며, 기본적으로 {}println!에게 Display라고 알려진 포맷팅을 이용하라고 전달해줍니다: 직접적인 최종 사용자가 사용하도록 의도된 출력이지요. 여지껏 우리가 봐온 기본 타입들은 Display가 기본적으로 구현되어 있는데, 이는 1 혹은 다른 기본 타입을 유저에게 보여주고자 하는 방법이 딱 한가지기 때문입니다. 하지만 구조체를 사용하는 경우, println!이 출력을 형식화하는 방법은 덜 명확한데 이는 표시 방법의 가능성이 더 많기 때문입니다: 여러분은 쉽표를 이용하길 원하나요, 혹은 그렇지 않은가요? 여러분은 중괄호를 출력하길 원하나요? 모든 필드들이 다 보여지는 편이 좋은가요? 이러한 모호성 때문에, 러스트는 우리가 원하는 것을 추론하는 시도를 하지 않으며 구조체는 Display에 대한 기본 제공 되는 구현체를 가지고 있지 않습니다.

계속 에러를 읽어나가면, 아래와 같은 도움말을 찾게 될 것입니다:

note: `Rectangle` cannot be formatted with the default formatter; try using
`:?` instead if you are using a format string

한번 시도해보죠! println! 매크로 호출은 이제 println!("rect1 is {:?}", rect1);처럼 보이게 될 것입니다. {} 내에 :? 명시자를 집어넣는 것은 println!에게 Debug라 불리우는 출력 포맷을 사용하고 싶다고 말해줍니다. Debug는 개발자에게 유용한 방식으로 우리의 구조체를 출력할 수 있도록 해줘서 우리 코드를 디버깅 하는 동안 그 값을 볼수 있게 해주는 트레잇입니다.

이 변경을 가지고 코드를 실행해보세요. 젠장! 여전히 에러가 납니다:

error: the trait bound `Rectangle: std::fmt::Debug` is not satisfied

하지만 또다시, 컴파일러가 우리에게 도움말을 제공합니다:

note: `Rectangle` cannot be formatted using `:?`; if it is defined in your
crate, add `#[derive(Debug)]` or manually implement it

러스트는 디버깅 정보를 출력하는 기능을 포함하고 있는 것이 맞지만, 우리 구조체에 대하여 해당 기능을 활성화하도록 명시적인 사전동의를 해주어야 합니다. 그러기 위해서, Listing 5-11에서 보는 바와 같이 구조체 정의부분 바로 전에 #[derive(Debug)] 어노테이션을 추가합니다:

Filename: src/main.rs

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

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

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

Listing5-11: Debug 트레잇을 파생시키기 위한 어노테이션의 추가 및 디버그 포맷팅을 이용한 Rectangle 인스턴스의 출력

이제 프로그램을 실행시키면, 에러는 사라지고 다음과 같은 출력을 보게될 것입니다:

rect1 is Rectangle { length: 50, width: 30 }

좋아요! 이게 제일 예쁜 출력은 아니지만, 이 인스턴스를 위한 모든 필드의 값을 보여주는데, 이는 디버깅 하는 동안 분명히 도움이 될 것입니다. 우리가 더 큰 구조체를 가지게 됐을 때는, 읽기 좀 더 수월한 출력을 쓰는 것이 유용합니다; 그러한 경우, println! 스트링 내에 {:?} 대신 {:#?}을 사용할 수 있습니다. 예제 내에서 {:#?} 스타일을 이용하게 되면, 출력이 아래와 같이 생기게 될 것입니다:

rect1 is Rectangle {
    length: 50,
    width: 30
}

러스트는 우리를 위해 derive 어노테이션을 이용한 여러 트레잇을 제공하여 우리의 커스텀 타입에 대해 유용한 동작을 추가할 수 있도록 해줍니다. 이 트레잇들과 그 동작들은 부록 C에서 그 목록을 찾을 수 있습니다. 10장에서는 이 트레잇들을 커스터마이징된 동작을 수행하도록 구현하는 방법 뿐만 아니라 우리만의 트레잇을 만드는 방법에 대해 다룰 것입니다.

우리의 area 함수는 매우 특정되어 있습니다: 딱 사각형의 면적만 계산합니다. 이 동작을 우리의 Rectangle 구조체와 더 가까이 묶을 수 있다면 유용할텐데요, 그 이유는 이 함수가 다른 타입과는 작동하지 않기 때문입니다. area 함수를 Rectangle 타입 내에 정의된 area 메소드로 바꾸어서 이 코드를 어떻게 더 리팩터링할 수 있는지 살펴봅시다.

메소드 문법

메소드(method) 는 함수와 유사합니다: 이들은 fn 키워드와 이름을 가지고 선언되고, 파라미터와 반환값을 가지고 있으며, 다른 어딘가로부터 호출되었을때 실행될 어떤 코드를 담고 있습니다. 하지만, 메소드는 함수와는 달리 구조체의 내용 안에 정의되며 (혹은 열거형이나 트레잇 객체 안에 정의되는데, 이는 6장과 17장에서 각각 다루겠습니다), 첫번째 파라미터가 언제나 self인데, 이는 메소드가 호출되고 있는 구조체의 인스턴스를 나타냅니다.

메소드 정의하기

Listing 5-12에서 보는 바와 같이 Rectangle 인스턴스를 파라미터로 가지고 있는 area 함수를 바꿔서 그 대신 Rectangle 구조체 위에서 정의된 area 메소드를 만들어 봅시다:

Filename: src/main.rs

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

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

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

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

Listing 5-12: Rectangle 구조체 상에 area 메소드 정의하기

Rectangle의 내용 안에 함수를 정의하기 위해서, impl (구현: implementation) 블록을 시작합니다. 그 다음 area 함수를 impl 중괄호 안으로 옮기고 시그니처 및 본체 내의 모든 곳에 있는 첫번째 파라미터 (지금의 경우에는 유일한 파라미터)를 self로 변경시킵니다. 우리가 area 함수를 호출하고 여기에 rect1을 인자로 넘기고 있는 main 함수에서는, 이 대신 Rectangle 인스턴스 상의 area 메소드를 호출하기 위해서 메소드 문법(method syntax) 를 이용할 수 있습니다. 메소드 문법은 인스턴스 다음에 위치합니다: 점을 추가하고 그 뒤를 이어 메소드 이름, 괄호, 인자들이 따라옵니다.

area의 시그니처 내에서는, rectangle: &Rectangle 대신 &self가 사용되었는데 이는 메소드가 impl Rectangle 내용물 안에 위치하고 있어 러스트가 self의 타입이 Rectangle 라는 사실을 알 수 있기 때문입니다. 우리가 &Rectangle이라고 썼던 것 처럼, self 앞에도 여전히 &를 사용할 필요가 있음을 주목하세요. 메소드는 self의 소유권을 가져갈 수도, 여기서처럼 self를 변경 불가능하게 빌릴 수도, 혹은 다른 파라미터와 비슷하게 변경이 가능하도록 빌려올 수도 있습니다.

여기서는 함수 버전에서 &Rectangle을 이용한 것과 같은 이유로 &self를 택했습니다: 우리는 소유권을 가져오는 것을 원하지 않으며, 다만 구조체 내의 데이터를 읽기만 하고, 쓰고 싶지는 않습니다. 만일 그 메소드가 동작하는 과정에서 메소드 호출에 사용된 인스턴스가 변하기를 원했다면, 첫번째 파라미터로 &mut self를 썼을테지요. 그냥 self을 첫번째 파라미터로 사용하여 인스턴스의 소유권을 가져오는 메소드를 작성하는 일은 드뭅니다; 이러한 테크닉은 보통 해당 메소드가 self을 다른 무언가로 변형시키고 이 변형 이후에 호출하는 측에서 원본 인스턴스를 사용하는 것을 막고 싶을 때 종종 쓰입니다.

함수 대신 메소드를 이용하면 생기는 주요 잇점은, 메소드 문법을 이용하여 모든 메소드 시그니처 내에서마다 self를 반복하여 타이핑하지 않아도 된다는 점과 더불어, 조직화에 관한 점입니다. 우리 코드를 향후 사용할 사람들이 우리가 제공하는 라이브러리 내의 다양한 곳에서 Rectangle이 사용 가능한 지점을 찾도록 하는 것보다 하나의 impl 블록 내에 해당 타입의 인스턴스로 할 수 있는 모든 것을 모아두었습니다.

-> 연산자는 어디로 갔나요?

C++ 같은 언어에서는, 메소드 호출을 위해서 서로 다른 두 개의 연산자가 사용됩니다: 만일 어떤 객체의 메소드를 직접 호출하는 중이라면 .를 이용하고, 어떤 객체의 포인터에서의 메소드를 호출하는 중이고 이 포인터를 역참조할 필요가 있다면 ->를 쓰지요. 달리 표현하면, 만일 object가 포인터라면, object->something()(*object).something()과 비슷합니다.

러스트는 -> 연산자와 동치인 연산자를 가지고 있지 않습니다; 대신, 러스트는 자동 참조 및 역참조 (automatic referencing and dereferencing) 이라는 기능을 가지고 있습니다. 메소드 호출은 이 동작을 포함하는 몇 군데 중 하나입니다.

동작 방식을 설명해보겠습니다: 여러분이 object.something()이라고 메소드를 호출했을 때, 러스트는 자동적으로 &&mut, 혹은 *을 붙여서 object가 해당 메소드의 시그니처와 맞도록 합니다. 달리 말하면, 다음은 동일한 표현입니다:


# #![allow(unused_variables)]
#fn main() {
# #[derive(Debug,Copy,Clone)]
# struct Point {
#     x: f64,
#     y: f64,
# }
#
# impl Point {
#    fn distance(&self, other: &Point) -> f64 {
#        let x_squared = f64::powi(other.x - self.x, 2);
#        let y_squared = f64::powi(other.y - self.y, 2);
#
#        f64::sqrt(x_squared + y_squared)
#    }
# }
# let p1 = Point { x: 0.0, y: 0.0 };
# let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);
#}

첫번째 표현이 훨씬 깔끔해 보입니다. 이러한 자동 참조 동작은 메소드가 명확한 수신자-즉 self의 타입을 가지고 있기 떄문에 동작합니다. 수신자와 메소드의 이름이 주어질 때, 러스트는 해당 메소드가 읽는지 (&self) 혹은 변형시키는지 (&mut self), 아니면 소비하는지 (self)를 결정론적으로 알아낼 수 있습니다. 러스트가 메소드 수신자를 암묵적으로 빌리도록 하는 사실은 실사용 환경에서 소유권을 인간공학적으로 만드는 중요한 부분입니다.

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

Rectangle 구조체의 두번째 메소드를 구현하여 메소드 사용법을 연습해 봅시다. 이번에는 Rectangle의 인스턴스가 다른 Rectangle 인스턴스를 가져와서 이 두번째 Rectangleself내에 완전히 안에 들어갈 수 있다면 true를 반환하고, 그렇지 않으면 false를 반환하고 싶어합니다. 즉, can_hold 메소드를 정의했다면, Listing 5-13에서 제시하는 프로그램을 작성할 수 있기를 원합니다:

Filename: src/main.rs

fn main() {
    let rect1 = Rectangle { length: 50, width: 30 };
    let rect2 = Rectangle { length: 40, width: 10 };
    let rect3 = Rectangle { length: 45, width: 60 };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

Listing 5-13: 아직 작성하지 않은 can_hold 메소드를 이용하는 데모

그리고 기대하는 출력은 아래와 같게 될 것인데, 이는 rect2의 두 차원축은 모두 rect1의 것보다 작지만, rect3rect1에 비해 가로로 더 넓기 때문입니다:

Can rect1 hold rect2? true
Can rect1 hold rect3? false

메소드를 정의하기를 원한다는 것을 인지하고 있으니, 이는 impl Rectangle 블록 내에 오게될 것입니다. 메소드의 이름은 can_hold이고, 또다른 Rectangle의 불변 참조자를 파라미터로 갖을 것입니다. 파라미터의 타입이 어떤 것이 될지는 메소드를 호출하는 코드를 살펴봄으로써 알 수 있습니다: rect1.can_hold(&rect2)&rect2를 넘기고 있는데, 이는 Rectangle의 인스턴스인 rect2의 불변성 빌림입니다. 이는 우리가 rect2를 그냥 읽기만 하길 원하기 때문에 타당하며 (쓰기를 원하는 것은 아니지요. 이는 곧 가변 빌림이 필요함을 의미합니다), mainrect2의 소유권을 유지하여 can_hold 메소드 호출 이후에도 이를 다시 사용할 수 있길 원합니다. can_hold의 반환값은 부울린이 될 것이고, 이 구현은 self의 길이와 너비가 다른 Rectangle의 길이와 너비보다 둘다 각각 큰지를 검사할 것입니다. Listing 5-14에서 보는 것처럼, 이 새로운 can_hold 메소드를 Listing 5-12의 impl 블록에 추가해 봅시다:

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# #[derive(Debug)]
# struct Rectangle {
#     length: u32,
#     width: u32,
# }
#
impl Rectangle {
    fn area(&self) -> u32 {
        self.length * self.width
    }

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

Listing 5-14: 또다른 Rectangle 인스턴스를 파라미터로 갖는 can_hold 메소드를 Rectangle 상에 구현하기

Listing 5-13에 있는 main 함수와 함께 이 코드를 실행하면, 원하는 출력을 얻을 수 있을 것입니다. 메소드는 self 파라미터 뒤에 추가된 여러 개의 파라미터를 가질 수 있으며, 이 파라미터들은 함수에서의 파라미터와 동일하게 기능합니다.

연관 함수

impl 블록의 또다른 유용한 기능은 self 파라미터를 갖지 않는 함수도 impl 내에 정의하는 것이 허용된다는 점입니다. 이를 연관 함수 (associated functions) 라고 부르는데, 그 이유는 이 함수가 해당 구조체와 연관되어 있기 때문입니다. 이들은 메소드가 아니라 여전히 함수인데, 이는 함께 동작할 구조체의 인스턴스를 가지고 있지 않아서 그렇습니다. 여러분은 이미 String::from 연관 함수를 사용해본 적이 있습니다.

연관 함수는 새로운 구조체의 인스턴스를 반환해주는 생성자로서 자주 사용됩니다. 예를 들면, 하나의 차원값 파라미터를 받아서 이를 길이와 너비 양쪽에 사용하여, 정사각형 Rectangle을 생성할 때 같은 값을 두번 명시하도록 하는 것보다 쉽게 해주는 연관 함수를 제공할 수 있습니다:

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# #[derive(Debug)]
# struct Rectangle {
#     length: u32,
#     width: u32,
# }
#
impl Rectangle {
    fn square(size: u32) -> Rectangle {
        Rectangle { length: size, width: size }
    }
}
#}

이 연관 함수를 호출하기 위해서는 let sq = Rectangle::square(3); 처럼, 구조체 이름과 함께 :: 문법을 이용합니다. 이 함수는 구조체의 이름공간 내에 있습니다: :: 문법은 연관 함수와 모듈에 의해 생성된 이름공간 두 곳 모두에서 사용되는데, 모듈에 대해서는 7장에서 다룰 것입니다.

정리

구조체는 우리의 문제 영역에 대해 의미있는 커스텀 타입을 만들수 있도록 해줍니다. 구조체를 이용함으로써, 우리는 연관된 데이터의 조각들을 서로 연결하여 유지할 수 있으며 각 데이터 조각에 이름을 붙여 코드를 더 명확하게 만들어 줄 수 있습니다. 메소드는 우리 구조체의 인스턴스가 가지고 있는 동작을 명시하도록 해주며, 연관 함수는 이용 가능한 인스턴스 없이 우리의 구조체에 특정 기능을 이름공간 내에 넣을 수 있도록 해줍니다.

하지만 구조체가 커스텀 타입을 생성할 수 있는 유일한 방법은 아닙니다: 러스트의 열거형 기능으로 고개를 돌려 우리의 도구상자에 또다른 도구를 추가하도록 합니다.

열거형과 패턴 매칭

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

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

열거형 정의하기

코드를 작성할 때, 열거형이 구조체보다 유용하고 적절하게 사용되는 상황에 대해서 살펴볼 것입니다. IP 주소를 다뤄야 하는 경우를 생각해 봅시다. 현재 IP 주소에는 두 개의 주요한 표준이 있습니다: 버전 4와 버전 6입니다. 프로그램에서 다룰 IP 주소의 경우의 수는 이 두 가지가 전부입니다: 모든 가능한 값들을 나열(enumerate) 할 수 있으며, 이 경우를 열거라고 부를 수 있습니다.

IP 주소는 버전 4나 버전 6중 하나이며, 동시에 두 버전이 될 수는 없습니다. IP 주소의 속성을 보면 열거형 자료 구조가 적절합니다. 왜냐하면, 열거형의 값은 variants 중 하나만 될 수 있기 때문입니다. 버전 4나 버전 6은 근본적으로 IP 주소이기 때문에, 이 둘은 코드에서 모든 종류의 IP 주소에 적용되는 상황을 다룰 때 동일한 타입으로 처리되는 것이 좋습니다.

IpAddrKind 이라는 열거형을 정의하면서 포함할 수 있는 IP 주소인 V4V6 를 나열함으로써 이 개념을 코드에 표현할 수 있습니다. 이것들은 열거형의 variants 라고 합니다:


# #![allow(unused_variables)]
#fn main() {
enum IpAddrKind {
    V4,
    V6,
}
#}

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

열거형 값

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


# #![allow(unused_variables)]
#fn main() {
# enum IpAddrKind {
#     V4,
#     V6,
# }
#
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
#}

열거형의 variants 는 열거형을 정의한 식별자에 의해 이름 공간이 생기며, 두 개의 콜론을 사용하여 둘을 구분할 수 있습니다. IpAddrKind::V4IpAddrKind::V6 의 값은 동일한 타입이기 때문에, 이 방식이 유용합니다: IpAddrKind. 이제 IpAddrKind 타입을 인자로 받는 함수를 정의할 수 있습니다:


# #![allow(unused_variables)]
#fn main() {
# enum IpAddrKind {
#     V4,
#     V6,
# }
#
fn route(ip_type: IpAddrKind) { }
#}

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


# #![allow(unused_variables)]
#fn main() {
# enum IpAddrKind {
#     V4,
#     V6,
# }
#
# fn route(ip_type: IpAddrKind) { }
#
route(IpAddrKind::V4);
route(IpAddrKind::V6);
#}

열거형을 사용하면 이점이 더 있습니다. IP 주소 타입에 대해 더 생각해 볼 때, 지금으로써는 실제 IP 주소 데이터를 저장할 방법이 없습니다. 단지 어떤 종류 인지만 알 뿐입니다. 5장에서 구조체에 대해 방금 공부했다고 한다면, 이 문제를 Listing 6-1에서 보이는 것처럼 풀려고 할 것입니다:


# #![allow(unused_variables)]
#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 저장하기

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

각 열거형 variant 에 데이터를 직접 넣는 방식을 사용해서 열거형을 구조체의 일부로 사용하는 방식보다 더 간결하게 동일한 개념을 표현할 수 있습니다. IpAddr 열거형의 새로운 정의에서는 두 개의 V4V6 variant 는 연관된 String 타입의 값을 갖게 됩니다.


# #![allow(unused_variables)]
#fn main() {
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 는 다른 타입과 다른 양의 연관된 데이터를 가질 수 있습니다. 버전 4 타입의 IP 주소는 항상 0 ~ 255 사이의 숫자 4개로 된 구성요소를 갖게 될 것입니다. V4 주소에 4개의 u8 값을 저장하길 원하지만, V6 주소는 하나의 String 값으로 표현되길 원한다면, 구조체로는 이렇게 할 수 없습니다. 열거형은 이런 경우를 쉽게 처리합니다:


# #![allow(unused_variables)]
#fn main() {
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_variables)]
#fn main() {
struct Ipv4Addr {
    // details elided
}

struct Ipv6Addr {
    // details elided
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
#}

이 코드에서 보듯이 열거형 variant 에 어떤 종류의 데이터라도 넣을 수 있습니다: 예를 들면 문자열, 숫자 타입, 혹은 구조체. 다른 열거형 조차도 포함할 수 있습니다! 또한 표준 라이브러리 타입들은 어떤 경우에는 해결책으로 생각한 것보다 훨씬 더 복잡하지 않습니다.

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

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


# #![allow(unused_variables)]
#fn main() {
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}
#}

Listing 6-2: Message 열거형은 각 variants 가 다른 타입과 다른 양의 값을 저장함.

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

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

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


# #![allow(unused_variables)]
#fn main() {
struct QuitMessage; // 유닛 구조체
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // 튜플 구조체
struct ChangeColorMessage(i32, i32, i32); // 튜플 구조체
#}

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

열거형과 구조체는 한 가지 더 유사한 점이 있습니다: 구조체에 impl 을 사용해서 메소드를 정의한 것처럼, 열거형에도 정의할 수 있습니다. 여기 Message 열거형에 에 정의한 call 이라는 메소드가 있습니다:


# #![allow(unused_variables)]
#fn main() {
# enum Message {
#     Quit,
#     Move { x: i32, y: i32 },
#     Write(String),
#     ChangeColor(i32, i32, i32),
# }
#
impl Message {
    fn call(&self) {
        // 메소드 내용은 여기 정의할 수 있습니다.
    }
}

let m = Message::Write(String::from("hello"));
m.call();
#}

열거형의 값을 가져오기 위해 메소드 안에서 self 를 사용할 것입니다. 이 예제에서 생성한 변수 mMessage::Write(String::from("hello")) 값을 갖게 되고, 이 값은 m.call()이 실행될 때, call 메소드 안에서 self가 될 것입니다.

표준 라이브러리에 있는 매우 흔하게 사용하고 유용한 열거형을 살펴봅시다: Option.

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

이전 절에서, IpAddr 열거형을 사용하여 작성한 프로그램에서는 러스트 타입 시스템을 사용하여 데이터뿐만 아니라 더 많은 정보를 담을 수 있는 방법을 살펴보았습니다.

이번 절에서는 표준 라이브러리에서 열거형으로 정의된 또 다른 타입인 Option 에 대한 사용 예를 살펴볼 것입니다. Option 타입은 많이 사용되는데, 값이 있거나 없을 수도 있는 아주 흔한 상황을 나타내기 때문입니다. 이 개념을 타입 시스템의 관점으로 표현하자면, 컴파일러가 발생할 수 있는 모든 경우를 처리했는지 체크할 수 있습니다. 이렇게 함으로써 버그를 방지할 수 있고, 이것은 다른 프로그래밍 언어에서 매우 흔합니다.

프로그래밍 언어 디자인은 가끔 어떤 특성들이 포함되었는지의 관점에서 생각되기도 하지만, 포함되지 않은 특성들도 역시 중요합니다. 러스트는 다른 언어들에서 흔하게 볼 수 있는 null 특성이 없습니다. Null 은 값이 없다는 것을 표현하는 하나의 값입니다. null 을 허용하는 언어에서는, 변수는 항상 두 상태중 하나가 될 수 있습니다: null 혹은 null 이 아님.

null 을 고안한 Tony Hoare 의 "Null 참조 : 10 억 달러의 실수"에서 다음과 같이 말합니다:

나는 그것을 나의 10억 달러의 실수라고 생각한다. 그 당시 객체지향 언어에서 처음 참조를 위한 포괄적인 타입 시스템을 디자인하고 있었다. 내 목표는 컴파일러에 의해 자동으로 수행되는 체크를 통해 모든 참조의 사용은 절대적으로 안전하다는 것을 확인하는 것이었다. 그러나 null 참조를 넣고 싶은 유혹을 참을 수 없었다. 간단한 이유는 구현이 쉽다는 것이었다. 이것은 수없이 많은 오류와 취약점들, 시스템 종료를 유발했고, 지난 40년간 10억 달러의 고통과 손실을 초래했을 수도 있다.

null 값으로 발생하는 문제는, null 값을 null 이 아닌 값처럼 사용하려고 할 때 여러 종류의 오류가 발생할 수 있다는 것입니다. null이나 null이 아닌 속성은 어디에나 있을 수 있고, 너무나도 쉽게 이런 종류의 오류를 만들어 냅니다.

그러나, null 이 표현하려고 하는 것은 아직까지도 유용합니다: null 은 현재 어떤 이유로 유효하지 않고, 존재하지 않는 하나의 값입니다.

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


# #![allow(unused_variables)]
#fn main() {
enum Option<T> {
    Some(T),
    None,
}
#}

Option<T> 열거형은 매우 유용하며 기본적으로 포함되어 있기 때문에, 명시적으로 가져오지 않아도 사용할 수 있습니다. 또한 variants 도 마찬가지입니다: Option:: 를 앞에 붙이지 않고, SomeNone 을 바로 사용할 수 있습니다. Option<T> 는 여전히 일반적인 열거형이고, Some(T)None 도 여전히 Option<T> 의 variants 입니다.

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


# #![allow(unused_variables)]
#fn main() {
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 (T 는 어떤 타입이던 될 수 있음)는 다른 타입이며, 컴파일러는 Option<T> 값을 명확하게 유효한 값처럼 사용하지 못하도록 합니다. 예를 들면, 아래 코드는 Option<i8>i8 을 더하려고 하기 때문에 컴파일되지 않습니다:

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

let sum = x + y;

이 코드를 실행하면, 아래와 같은 에러 메시지가 출력됩니다:

error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is
not satisfied
 -->
  |
7 | let sum = x + y;
  |           ^^^^^
  |

주목하세요! 실제로, 이 에러 메시지는 러스트가 Option<i8>i8 를 어떻게 더해야 하는지 모른다는 것을 의미하는데, 둘은 다른 타입이기 때문입니다. 러스트에서 i8 과 같은 타입의 값을 가질 때, 컴파일러는 항상 유효한 값을 갖고 있다는 것을 보장할 것입니다. 값을 사용하기 전에 null 인지 확인할 필요도 없이 자신 있게 사용할 수 있습니다. 단지 Option<i8> 을 사용할 경우엔 (혹은 어떤 타입 이건 간에) 값이 있을지 없을지에 대해 걱정할 필요가 있으며, 컴파일러는 값을 사용하기 전에 이런 케이스가 처리되었는지 확인해 줄 것입니다.

다르게 얘기하자면, 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 에 따라 다른 코드를 실행할 것이고, 그 코드는 매칭 된 값에 있는 데이터를 사용할 수 있습니다.

match 흐름 제어 연산자

러스트는 match라고 불리는 극도로 강력한 흐름 제어 연산자를 가지고 있는데 이는 우리에게 일련의 패턴에 대해 어떤 값을 비교한 뒤 어떤 패턴에 매치되었는지를 바탕으로 코드를 수행하도록 해줍니다. 패턴은 리터럴 값, 변수명, 와일드카드, 그리고 많은 다른 것들로 구성될 수 있습니다; 18장에서 다른 모든 종류의 패턴들과 이것들로 할 수 있는 것에 대해 다룰 것입니다. match의 힘은 패턴의 표현성으로부터 오며 컴파일러는 모든 가능한 경우가 다루어지는지를 검사합니다.

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

우리가 방금 동전들을 언급했으니, match를 이용한 예제로 동전들을 이용해봅시다! Listing 6-3에서 보는 바와 같이, 우리는 익명의 미국 동전을 입력받아서, 동전 계수기와 동일한 방식으로 그 동전이 어떤 것이고 센트로 해당 값을 반환하는 함수를 작성할 수 있습니다.


# #![allow(unused_variables)]
#fn main() {
enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}
#}

Listing 6-3: 열거형과 열거형의 variant를 패턴으로서 사용하는 match 표현식

value_in_cents 함수 내의 match를 쪼개 봅시다. 먼저, match 키워드 뒤에 표현식을 써줬는데, 위의 경우에는 coin 값입니다. 이는 if를 사용한 표현식과 매우 유사하지만, 큰 차이점이 있습니다: if를 사용하는 경우, 해당 표현식은 부울린 값을 반환할 필요가 있습니다. 여기서는 어떤 타입이든 가능합니다. 위 예제에서 coin의 타입은 Listing 6-3에서 정의했던 Coin 열거형입니다.

다음은 match 갈래(arm)들입니다. 하나의 갈래는 두 부분을 갖고 있습니다: 패턴과 어떤 코드로 되어 있죠. 여기서의 첫 번째 갈래는 값 Coin::Penny로 되어있는 패턴을 가지고 있고 그 후에 패턴과 실행되는 코드를 구분해주는 => 연산자가 있습니다. 위의 경우에서 코드는 그냥 값 1입니다. 각 갈래는 그다음 갈래와 쉼표로 구분됩니다.

match 표현식이 실행될 때, 결과 값을 각 갈래의 패턴에 대해서 순차적으로 비교합니다. 만일 어떤 패턴이 그 값과 매치되면, 그 패턴과 연관된 코드가 실행됩니다. 만일 그 패턴이 값과 매치되지 않는다면, 동전 분류기와 비슷하게 다음 갈래로 실행을 계속합니다.

각 갈래와 연관된 코드는 표현식이고, 이 매칭 갈래에서의 표현식의 결과 값은 전체 match 표현식에 대해 반환되는 값입니다.

각 갈래가 그냥 값을 리턴하는 Listing 6-3에서처럼 매치 갈래의 코드가 짧다면, 중괄호는 보통 사용하지 않습니다. 만일 매치 갈래 내에서 여러 줄의 코드를 실행시키고 싶다면, 중괄호를 이용할 수 있습니다. 예를 들어, 아래의 코드는 Coin::Penny와 함께 메소드가 호출될 때마다 “Lucky penny!”를 출력하지만 여전히 해당 블록의 마지막 값인 1을 반환할 것입니다:


# #![allow(unused_variables)]
#fn main() {
# enum Coin {
#    Penny,
#    Nickel,
#    Dime,
#    Quarter,
# }
#
fn value_in_cents(coin: Coin) -> u32 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        },
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}
#}

값들을 바인딩하는 패턴들

매치 갈래의 또 다른 유용한 기능은 패턴과 매치된 값들의 부분을 바인딩할 수 있다는 것입니다. 이것이 열거형 variant로부터 어떤 값들을 추출할 수 있는 방법입니다.

한 가지 예로서, 우리의 열거형 variant 중 하나를 내부에 값을 들고 있도록 바꿔봅시다. 1999년부터 2008년까지, 미국은 각 50개 주마다 한쪽 면의 디자인이 다른 쿼터 동전을 주조했습니다. 다른 동전들은 주의 디자인을 갖지 않고, 따라서 오직 쿼터 동전들만 이 특별 값을 갖습니다. 우리는 이 정보를 Quarter variant 내에 UsState 값을 포함하도록 우리의 enum을 변경함으로써 추가할 수 있는데, 이는 Listing 6-4에서 한 바와 같습니다:


# #![allow(unused_variables)]
#fn main() {
#[derive(Debug)] // So we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // ... etc
}

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

Listing 6-4: Quarter variant가 UsSate 값 또한 들고 있는 Coin 열거형

우리의 친구가 모든 50개 주 쿼터 동전을 모으기를 시도하는 중이라고 상상해봅시다. 동전의 종류에 따라 동전을 분류하는 동안, 우리는 또한 각 쿼터 동전에 연관된 주의 이름을 외쳐서, 만일 그것이 우리 친구가 가지고 있지 않은 것이라면, 그 친구는 자기 컬렉션에 그 동전을 추가할 수 있겠지요.

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


# #![allow(unused_variables)]
#fn main() {
# #[derive(Debug)]
# enum UsState {
#    Alabama,
#    Alaska,
# }
#
# enum Coin {
#    Penny,
#    Nickel,
#    Dime,
#    Quarter(UsState),
# }
#
fn value_in_cents(coin: Coin) -> u32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {:?}!", state);
            25
        },
    }
}
#}

만일 우리가 value_in_cents(Coin::Quarter(UsState::Alaska))를 호출했다면, coinCoin::Quarter(UsState::Alaska)가 될 테지요. 각각의 매치 갈래들과 이 값을 비교할 때, Coin::Quarter(state)에 도달할 때까지 아무것도 매치되지 않습니다. 이 시점에서, state에 대한 바인딩은 값 UsState::Alaska가 될 것입니다. 그러면 이 바인딩을 println! 표현식 내에서 사용할 수 있고, 따라서 Quarter에 대한 Coin 열거형 variant로부터 내부의 주에 대한 값을 얻었습니다.

Option<T>를 이용하는 매칭

이전 절에서 Option<T>을 사용할 때 Some 케이스로부터 내부의 T 값을 얻을 필요가 있었습니다; 우리는 Coin 열거형을 가지고 했던 것처럼 match를 이용하여 Option<T>를 다룰 수 있습니다! 동전들을 비교하는 대신, Option<T>의 variant를 비교할 것이지만, match 표현식이 동작하는 방법은 동일하게 남아있습니다.

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

match에 감사하게도, 이 함수는 매우 작성하기 쉽고, Listing 6-5와 같이 보일 것입니다:


# #![allow(unused_variables)]
#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);
#}

Listing 6-5: Option<i32> 상에서 match를 이용하는 함수

Some(T) 매칭 하기

plus_one의 첫 번째 실행을 좀 더 자세히 시험해봅시다. plus_one(five)가 호출될 때, plus_one의 본체 내의 변수 x는 값 Some(5)를 갖게 될 것입니다. 그런 다음 각각의 매치 갈래에 대하여 이 값을 비교합니다.

None => None,

Some(5) 값은 패턴 None과 매칭 되지 않으므로, 다음 갈래로 계속 갑니다.

Some(i) => Some(i + 1),

Some(5)Some(i)랑 매칭 되나요? 예, 바로 그렇습니다! 동일한 variant를 갖고 있습니다. Some 내부에 담긴 값은 i에 바인드 되므로, i는 값 5를 갖습니다. 그런 다음 매치 갈래 내의 코드가 실행되므로, i의 값에 1을 더한 다음 최종적으로 6을 담은 새로운 Some 값을 생성합니다.

None 매칭 하기

이제 xNone인 Listing 6-5에서의 plus_one의 두 번째 호출을 살펴봅시다. match 안으로 들어와서 첫 번째 갈래와 비교합니다.

None => None,

매칭 되었군요! 더할 값은 없으므로, 프로그램은 멈추고 =>의 우측 편에 있는 None 값을 반환합니다. 첫 번째 갈래에 매칭 되었으므로, 다른 갈래와는 비교하지 않습니다.

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

매치는 하나도 빠뜨리지 않습니다

우리가 논의할 필요가 있는 match의 다른 관점이 있습니다. plus_one 함수의 아래 버전을 고려해 보세요:

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        Some(i) => Some(i + 1),
    }
}

여기서는 None 케이스를 다루지 않았고, 따라서 이 코드는 버그를 일으킬 것입니다. 다행히도, 이는 러스트가 어떻게 잡는지 알고 있는 버그입니다. 이 코드를 컴파일하고자 시도하면, 아래와 같은 에러를 얻게 됩니다:

error[E0004]: non-exhaustive patterns: `None` not covered
 -->
  |
6 |         match x {
  |               ^ pattern `None` not covered

러스트는 우리가 다루지 않은 모든 가능한 경우를 알고 있고, 심지어 우리가 어떤 패턴을 잊어먹었는지도 알고 있습니다! 러스트에서 매치는 하나도 빠뜨리지 않습니다(exhaustive): 코드가 유효해지기 위해서는 모든 마지막 가능성까지 샅샅이 다루어야 합니다. 특히 Option<T>의 경우, 즉 러스트가 우리로 하여금 None 케이스를 명시적으로 다루는 일을 잊는 것을 방지하는 경우에는, Null 일지도 모를 값을 가지고 있음을 가정하여, 앞서 논의했던 수십억 달러짜리 실수를 하는 일을 방지해줍니다.

_ 변경자(placeholder)

러스트는 또한 우리가 모든 가능한 값을 나열하고 싶지 않을 경우에 사용할 수 있는 패턴을 가지고 있습니다. 예를 들어, u8은 0에서부터 255까지 유효한 값을 가질 수 있습니다. 만일 우리가 1, 3, 5, 그리고 7 값에 대해서만 신경 쓰고자 한다면, 나머지 0, 2, 4, 6, 8, 그리고 9부터 255까지를 모두 나열하고 싶진 않을 겁니다. 다행히도, 그럴 필요 없습니다: 대신 특별 패턴인 _를 이용할 수 있습니다.


# #![allow(unused_variables)]
#fn main() {
let some_u8_value = 0u8;
match some_u8_value {
    1 => println!("one"),
    3 => println!("three"),
    5 => println!("five"),
    7 => println!("seven"),
    _ => (),
}
#}

_ 패턴은 어떠한 값과도 매칭 될 것입니다. 우리의 다른 갈래 뒤에 이를 집어넣음으로써, _는 그전에 명시하지 않은 모든 가능한 경우에 대해 매칭 될 것입니다. ()는 단지 단위 값이므로, _ 케이스에서는 어떤 일도 일어나지 않을 것입니다. 결과적으로, 우리가 _ 변경자 이전에 나열하지 않은 모든 가능한 값들에 대해서는 아무것도 하고 싶지 않다는 것을 말해줄 수 있습니다.

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

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

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


# #![allow(unused_variables)]
#fn main() {
let some_u8_value = Some(0u8);
match some_u8_value {
    Some(3) => println!("three"),
    _ => (),
}
#}

Listing 6-6: 어떤 값이 Some(3) 일 때에만 코드를 실행하도록 하는 match

우리는 Some(3)에 매칭 되는 경우에만 뭔가를 하지만 다른 Some<u8> 값 혹은 None 값인 경우에는 아무것도 하지 않고 싶습니다. 이러한 match 표현식을 만족시키기 위해, _ => ()을 단 하나의 variant를 처리한 다음에 추가해야 하는데, 이는 추가하기에 너무 많은 보일러 플레이트 코드입니다.

그 대신, if let을 이용하여 이 코드를 더 짧게 쓸 수 있습니다. 아래의 코드는 Listing 6-6에서의 match와 동일하게 동작합니다:


# #![allow(unused_variables)]
#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가 강제했던 하나도 빠짐없는 검사를 잃게 되었습니다. matchif let 사이에서 선택하는 것은 여러분의 특정 상황에서 여러분이 하고 있는 것에 따라, 그리고 간결함을 얻는 것이 전수 조사를 잃는 것에 대한 적절한 거래인지에 따라 달린 문제입니다.

바꿔 말하면, 여러분은 if let를 어떤 값이 하나 패턴에 매칭 되었을 때 코드를 실행하고 다른 값들에 대해서는 무시하는 match 문을 위한 문법적 설탕(syntax sugar)으로 생각할 수 있습니다.

if let과 함께 else를 포함시킬 수 있습니다. else 뒤에 나오는 코드 블록은 match 표현식에서 _ 케이스 뒤에 나오는 코드 블록과 동일합니다. Listing 6-4에서 Quarter variant가 UsState 값도 들고 있었던 Coin 열거형 정의부를 상기해 보세요. 만일 우리가 쿼터가 아닌 모든 동전을 세고 싶은 동시에 쿼터 동전일 경우 또한 알려주고 싶었다면, 아래와 같이 match문을 쓸 수 있었을 겁니다:


# #![allow(unused_variables)]
#fn main() {
# #[derive(Debug)]
# enum UsState {
#    Alabama,
#    Alaska,
# }
#
# enum Coin {
#    Penny,
#    Nickel,
#    Dime,
#    Quarter(UsState),
# }
# let coin = Coin::Penny;
let mut count = 0;
match coin {
    Coin::Quarter(state) => println!("State quarter from {:?}!", state),
    _ => count += 1,
}
#}

혹은 아래와 같이 if letelse 표현식을 이용할 수도 있겠지요:


# #![allow(unused_variables)]
#fn main() {
# #[derive(Debug)]
# enum UsState {
#    Alabama,
#    Alaska,
# }
#
# enum Coin {
#    Penny,
#    Nickel,
#    Dime,
#    Quarter(UsState),
# }
# let coin = Coin::Penny;
let mut count = 0;
if let Coin::Quarter(state) = coin {
    println!("State quarter from {:?}!", state);
} else {
    count += 1;
}
#}

만일 여러분의 프로그램이 match로 표현하기에는 너무 수다스러운 로직을 가지고 있는 경우에 놓여 있다면, 여러분의 러스트 도구 상자에는 또한 if let이 있음을 기억하세요.

정리

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

여러분의 러스트 프로그램은 이제 구조체와 열거형을 이용해 여러분의 영역 내의 개념을 표현할 수 있습니다. 여러분의 API 내에서 사용할 커스텀 타입을 생성하는 것은 타입 안전성을 보장합니다: 컴파일러는 여러분의 특정 함수들이 예상하는 특정 타입의 값만 갖도록 만들어줄 것입니다.

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

모듈을 사용하여 코드를 재사용하고 조직화하기

여러분이 러스트로 프로그램을 작성하기 시작했을 때, 여러분의 코드는 오로지 main 함수 안에만 있을지도 모르겠습니다. 코드가 커짐에 따라서, 여러분은 재사용 및 더 나은 조직화를 위하여 결국 어떤 기능을 다른 함수로 이동시킬 것입니다. 코드를 더 작은 덩어리로 쪼갬으로서, 각각의 덩어리들은 개별적으로 이해하기 더 수월해집니다. 하지만 함수가 너무 많으면 어떤 일이 벌어질까요? 러스트는 조직화된 방식으로 코드의 재사용을 할 수 있게 해주는 모듈(module) 시스템을 갖추고 있습니다.

코드 몇줄을 함수로 추출하는 것과 같은 방식으로, 여러분은 함수 (혹은 구조체나 열거형 같은 다른 코드들)를 다른 모듈로 뽑아낼 수 있으며, 여러분은 이것들의 정의가 모듈의 바깥쪽에서 볼 수 있도록 하거나(public) 혹은 보이지 않게 하도록 (private) 선택할 수 있습니다. 모듈이 어떤 식으로 동작하는지에 대한 개요를 봅시다:

  • mod 키워드는 새로운 모듈을 선언합니다. 모듈 내의 코드는 이 선언 바로 뒤에 중괄호 로 묶여서 따라오거나 다른 파일에 놓일 수 있습니다.
  • 기본적으로, 함수, 타입, 상수, 그리고 모듈은 private입니다. pub 키워드가 어떤 아이템을 public하게 만들어줘서 이것의 네임스페이스 바깥쪽에서도 볼 수 있도록 합니다.
  • use 키워드는 모듈이나 모듈 내의 정의들을 스코프 안으로 가져와서 이들을 더 쉽게 참조할 수 있도록 합니다.

각각의 부분들을 살펴보면서 이것들이 전체적으로 어떻게 맞물리는지 살펴봅시다.

mod와 파일 시스템

먼저 카고를 이용해서 새로운 프로젝트를 만드는 것으로 모듈 예제를 시작하려고 하는데, 바이너리 크레이트(crate)을 만드는 대신에 라이브러리 크레이트을 만들 것입니다. 여기서 라이브러리 크레이트이란 다른 사람들이 자신들의 프로젝트에 디펜던시(dependency)로 추가할 수 있는 프로젝트를 말합니다. 예를 들어, 2장의 rand 크레이트은 우리가 추리 게임 프로젝트에서 디펜던시로 사용했던 라이브러리 크레이트입니다.

우리는 몇가지 일반적인 네트워크 기능을 제공하는 라이브러리의 뼈대를 만들 것입니다; 여기서는 모듈들과 함수들의 조직화에 집중할 것이고, 함수의 본체에 어떤 코드가 들어가야 하는지는 신경쓰지 않겠습니다. 이 라이브러리를 communicator라고 부르겠습니다. 기본적으로, 카고는 다른 타입의 프로젝트로 특정하지 않는 이상 라이브러리를 만들 것입니다: 이전의 모든 장들에서 사용해왔던 --bin 옵션을 제거하면, 프로젝트는 라이브러리가 될 것입니다:

$ cargo new communicator
$ cd communicator

카고가 src/main.rs 대신 src/lib.rs을 생성했음을 주목하세요. src/lib.rs 내부를 보면 다음과 같은 코드를 찾을 수 있습니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
    }
}
#}

카고는 우리가 만든 라이브러리의 작성 시작을 돕기 위해 빈 테스트를 만드는데, 이는 --bin 옵션을 사용했을때 “Hello, world!” 바이너리를 만들어준 것과 사뭇 다릅니다. #[]mod tests 문법은 이 장의 “super를 이용하여 부모 모듈에 접근하기”절에서 더 자세히 다룰 것이지만, 당장은 src/lib.rs의 아래쪽에 이 코드를 남겨두겠습니다.

src/main.rs 파일이 없기 떄문에, cargo run 커맨드로 카고가 실행할 것이 없습니다. 따라서, 여기서는 라이브러리 크레이트의 코드를 컴파일하기 위해 cargo build를 사용할 것입니다.

이제 여러분이 작성하는 코드의 의도에 따라 만들어지는 다양한 상황에 알맞도록 라이브러리 코드를 조직화하는 다양한 옵션들을 살펴보겠습니다.

모듈 정의

우리의 communicator 네트워크 라이브러리를 위해서, 먼저 connect라는 이름의 함수가 정의되어 있는 network라는 이름의 모듈을 정의하겠습니다. 러스트 내 모듈 정의는 모두 mod로 시작됩니다. 이 코드를 src/lib.rs의 시작 부분, 즉 테스트 코드의 윗 쪽에 추가해봅시다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
mod network {
    fn connect() {
    }
}
#}

mod 키워드 뒤에, 모듈의 이름 network가 쓰여지고 중괄호 안에 코드 블록이 옵니다. 이 블록 안의 모든 것은 이름공간 network 안에 있습니다. 위의 경우 connect라는 이름의 함수 하나가 있습니다. 이 함수를 network 모듈 바깥의 스크립트에서 호출하고자 한다면, 우리는 모듈을 특정할 필요가 있으므로 이름공간 문법 ::를 이용해야 합니다: connect() 이렇게만 하지 않고 network::connect() 이런 식으로요.

또한 같은 src/lib.rs 파일 내에 여러 개의 모듈을 나란히 정의할 수도 있습니다. 예를 들어, connect라는 이름의 함수를 갖고 있는 client 모듈을 정의하려면, Listing 7-1에 보시는 바와 같이 이를 추가할 수 있습니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
mod network {
    fn connect() {
    }
}

mod client {
    fn connect() {
    }
}
#}

Listing 7-1: src/lib.rs 내에 나란히 정의된 network 모듈과 client 모듈

이제 우리는 network::connect 함수와 client::connect 함수를 갖게 되었습니다. 이들은 완전히 다른 기능을 갖고 있을 수 있고, 서로 다른 모듈에 정의되어 있기 때문에 함수 이름이 서로 부딪힐 일은 없습니다.

이 경우, 우리가 라이브러리를 만드는 중이기 때문에, 라이브러리의 시작 지점으로서 제공되는 파일은 src/lib.rs 입니다. 하지만 모듈을 만드는 것에 관하여 src/lib.rs는 특별할 것이 없습니다. 우리는 라이브러리 크레이트의 src/lib.rs 내에 모듈을 만드는 것과 똑같은 방식으로 바이너리 크레이트의 src/main.rs 내에도 모듈을 만들 수 있습니다. 사실 모듈 안에 다른 모듈을 집어넣는 것도 가능한데, 이는 여러분의 모듈이 커짐에 따라 관련된 기능이 잘 조직화 되도록 하는 한편 각각의 기능을 잘 나누도록 하는데 유용할 수 있습니다. 여러분의 코드를 어떻게 조직화 할 것인가에 대한 선택은 여러분이 코드의 각 부분 간의 관계에 대해 어떻게 생각하고 있는지에 따라 달라집니다. 예를 들어, Listing 7-2와 같이 client 모듈과 connect 함수가 network 이름공간 내에 있다면 우리의 라이브러리 사용자가 더 쉽게 이해할지도 모릅니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
mod network {
    fn connect() {
    }

    mod client {
        fn connect() {
        }
    }
}
#}

Listing 7-2: client 모듈을 network 모듈 안으로 이동

src/lib.rs 파일에서 Listing 7-2와 같이 client 모듈이 network 모듈의 내부 모듈이 되도록 mod networkmod client의 위치를 바꿔 봅시다. 이제 우리는 network::connectnetwork::client::connect 함수를 갖게 되었습니다: 다시 말하지만, connect라는 이름의 두 함수는 서로 다른 이름공간에 있으므로 부딪힐 일이 없습니다.

이런 식으로 모듈들은 계층을 구성하게 됩니다. src/lib.rs의 내용은 가장 위의 층을 이루고, 서브 모듈들은 그보다 낮은 층에 있습니다. Listing 7-1 예제에서의 조직화가 계층 구조를 생각했을 때 어떻게 보일지 살펴봅시다:

communicator
 ├── network
 └── client

그리고 Listing 7-2 예제에 대응되는 계층 구조는 이렇습니다:

communicator
 └── network
     └── client

Listing 7-2에서 계층 구조는 clientnetwork의 형제이기 보다는 자식임을 보여줍니다. 더 복잡한 프로젝트는 많은 수의 모듈을 갖고 있을 수 있고, 이들은 지속적인 트래킹을 위해 논리적으로 잘 조직화될 필요가 있을 것입니다. 여러분의 프로젝트 내에서 “논리적으로”가 의미하는 것은 여러분에게 달려 있는 것이며, 여러분과 여러분의 라이브러리 사용자들이 프로젝트 도메인에 대해 어떻게 생각하는지에 따라 달라집니다. 여러분이 선호하는 어떤 형태의 구조이건 간에 여기서 보여준 나란한 모듈 및 중첩된(nested) 모듈을 만드는 테크닉을 이용해 보세요.

모듈을 다른 파일로 옮기기

모듈은 계층적인 구조를 형성하는데, 여러분이 익숙하게 사용하고 있는 다른 구조와 매우 닮았습니다: 바로 파일 시스템이죠! 러스트에서는 프로젝트를 잘게 나누기 위해 여러 개의 파일 상에서 모듈 시스템을 사용할 수 있어, 모든 것들이 src/lib.rssrc/main.rs 안에 존재하지 않게할 수 있습니다. 이러한 예를 위해서, Listing 7-3에 있는 코드를 시작해봅시다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
mod client {
    fn connect() {
    }
}

mod network {
    fn connect() {
    }

    mod server {
        fn connect() {
        }
    }
}
#}

Listing 7-3: 세 개의 모듈 client, network, network::server가 모두 src/lib.rs에 정의되어 있음

파일 src/lib.rs는 아래와 같은 모듈 계층을 갖고 있습니다:

communicator
 ├── client
 └── network
     └── server

만일 이 모듈들이 여러 개의 함수들을 갖고 있고, 이 함수들이 길어지고 있다면, 우리가 작업하고자 하는 코드를 찾으려고 이 파일을 스크롤 하기가 까다로워질 것입니다. 함수들은 하나 혹은 그 이상의 mod 블록 안에 포함되어 있기 떄문에, 함수 내의 코드 라인들 또한 길어지기 시작할 것입니다. 이는 client, network, 그리고 server 모듈을 src/lib.rs로부터 떼어내어 각자를 위한 파일들에 위치시키기 좋은 이유가 되겠습니다.

먼저 client 모듈의 코드를 client 모듈의 선언 부분만 남겨두는 것으로 바꾸세요. 그러니까 여러분의 src/lib.rs는 아래와 같이 될 것입니다:

Filename: src/lib.rs

mod client;

mod network {
    fn connect() {
    }

    mod server {
        fn connect() {
        }
    }
}

여기서는 여전히 client 모듈을 선언하고 있지만, 코드 블록을 세미콜론으로 대체함으로써, 우리는 러스트에게 client 모듈의 스코프 내에 정의된 코드를 다른 위치에서 찾으라고 말하는 것입니다. 달리 말하면, mod client;라는 라인의 뜻은 이렇습니다:

mod client {
    // contents of client.rs
}

이제 모듈의 이름과 같은 이름을 가진 외부 파일을 만들 필요가 있습니다. client.rs 파일을 여러분의 src/ 디렉토리에 생성하고 여세요. 그런 뒤 아래와 같이 앞 단계에서 제거했던 client 모듈내의 connect 함수를 입력해세요:

Filename: src/client.rs


# #![allow(unused_variables)]
#fn main() {
fn connect() {
}
#}

이미 src/lib.rs 안에다 client 모듈을 mod를 이용하여 선언을 했기 때문에, 이 파일 안에는 mod 선언이 필요없다는 점을 기억하세요. 이 파일은 단지 client 모듈의 내용물만 제공할 뿐입니다. 만일 mod client를 여기에 또 집어넣는다면, 이는 client 모듈 내에 서브모듈 client를 만들게 됩니다!

러스트는 기본적으로 src/lib.rs만 찾아볼줄 압니다. 만약에 더 많은 파일을 프로젝트에 추가하고 싶다면, src/lib.rs 내에서 다른 파일을 찾아보라고 러스트에게 말해줄 필요가 있습니다; 이는 mod client라는 코드가 왜 src/lib.rs 내에 정의될 필요가 있는지, 그리고 src/client.rs 내에는 정의될 수 없는지에 대한 이유입니다.

이제 몇 개의 컴파일 경고가 생기지만, 프로젝트는 성공적으로 컴파일 되어야 합니다. 우리가 바이너리 크레이트 대신 라이브러리 크레이트를 만드는 중이므로 cargo run 대신 cargo build를 이용해야 한다는 점을 기억해두세요:

$ cargo build
   Compiling communicator v0.1.0 (file:///projects/communicator)

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/client.rs:1:1
  |
1 | fn connect() {
  | ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/lib.rs:4:5
  |
4 |     fn connect() {
  |     ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/lib.rs:8:9
  |
8 |         fn connect() {
  |         ^

이 경고들은 사용된 적이 없는 함수가 있음을 우리에게 알려줍니다. 지금은 이 경고들을 너무 걱정하지 마세요: 이 장의 뒤에 나오는 “pub을 이용하여 가시성 제어하기”절에서 이 문제에 대해 알아볼 것입니다. 좋은 소식은 이들이 그냥 경고일 뿐이란 것입니다; 우리 프로젝트는 성공적으로 빌드됐습니다!

다음으로 같은 방식을 이용하여 network 모듈을 개별 파일로 추출해봅시다. src/lib.rs 안에서, 아래와 같이 network 모듈의 몸체를 지우고 선언부의 끝부분에 세미콜론을 붙이세요:

Filename: src/lib.rs

mod client;

mod network;

그리고나서 새로운 src/network.rs 파일을 만들어서 아래를 입력하세요:

Filename: src/network.rs


# #![allow(unused_variables)]
#fn main() {
fn connect() {
}

mod server {
    fn connect() {
    }
}
#}

이 모듈 파일 내에는 mod 선언이 여전히 있음을 주목하세요; 이는 servernetwork의 서브모듈로서 여전히 필요하기 때문입니다.

cargo build를 다시 실행시키세요. 성공! 여기 또 추출할만한 모듈이 하나 더 있습니다: server 말이죠. 이것이 서브모듈(즉, 모듈 내의 모듈)이기 때문에, 모듈을 파일로 추출해서 파일 이름을 모듈 이름으로 사용하는 전략은 사용하기 힘듭니다. 어쨌든 시도해서 에러를 확인해보겠습니다. 먼저, src/network.rs 내에서 server 모듈의 내용물 대신에 mod server을 쓰세요:

Filename: src/network.rs

fn connect() {
}

mod server;

그후 src/server.rs 파일을 만들고 추출해둔 server 모듈의 내용물을 입력하세요:

Filename: src/server.rs


# #![allow(unused_variables)]
#fn main() {
fn connect() {
}
#}

cargo build를 실행해보면, Listing 7-4와 같은 에러를 얻게 됩니다:

$ cargo build
   Compiling communicator v0.1.0 (file:///projects/communicator)
error: cannot declare a new module at this location
 --> src/network.rs:4:5
  |
4 | mod server;
  |     ^^^^^^
  |
note: maybe move this module `network` to its own directory via `network/mod.rs`
 --> src/network.rs:4:5
  |
4 | mod server;
  |     ^^^^^^
note: ... or maybe `use` the module `server` instead of possibly redeclaring it
 --> src/network.rs:4:5
  |
4 | mod server;
  |     ^^^^^^

Listing 7-4: server 서브모듈을 src/server.rs로 추출을 시도했을 때 발생하는 에러

에러는 이 위치에 새로운 모듈을 선언할수 없다고 말해주며 src/network.rsmod server; 라인을 지적하고 있습니다. src/network.rssrc/lib.rs와는 다소 다릅니다: 왜 그런지 이해하려면 계속 읽어주세요.

Listing 7-4의 중간의 노트는 실질적으로 매우 도움이 되는데, 그 이유는 우리가 아직 설명하지 않은 무언가를 지적하고 있기 때문입니다:

note: maybe move this module `network` to its own directory via
`network/mod.rs`

전에 사용했던 똑같은 파일 이름 쓰기 패턴을 계속해서 따르는 대신, 아래 노트에서 제안하는 것을 해볼 수 있습니다:

  1. 부모 모듈의 이름에 해당하는, network라는 이름의 새로운 디렉토리를 만드세요.
  2. src/network.rs 파일을 이 새로운 network 디렉토리 안으로 옮기고, 파일 이름을 src/network/mod.rs로 고치세요.
  3. 서브모듈 파일 src/server.rsnetwork 디렉토리 안으로 옮기세요.

위의 단계들을 실행하기 위한 명령들입니다:

$ mkdir src/network
$ mv src/network.rs src/network/mod.rs
$ mv src/server.rs src/network

이제 cargo build를 다시 실행하면, 컴파일은 작동할 것입니다 (여전히 경고는 좀 있지만요). 우리의 모듈 레이아웃은 여전히 아래와 같이 되는데, 이는 Listing 7-3의 src/lib.rs 내의 코드에서 만든 것과 정확하게 동일합니다:

communicator
 ├── client
 └── network
     └── server

이에 대응하는 파일 레이아웃는 아래와 같이 생겼습니다:

├── src
│   ├── client.rs
│   ├── lib.rs
│   └── network
│       ├── mod.rs
│       └── server.rs

그러니까 우리가 network::server 모듈을 추출하고자 할 때, 왜 network::server 모듈을 src/server.rs로 추출하는 대신, src/network.rs 파일에 src/network/mod.rs로 옮기고 network::server 코드를 network 디렉토리 안에 있는 src/network/server.rs에 넣었을까요? 그 이유는 src 디렉토리 안에 server.rs 파일이 있으면, 러스트는 servernetwork의 서브모듈이라고 인식할 수 없기 때문입니다. 러스트가 동작하는 방식을 명확하게 알기 위해서, 아래와 같은 모듈 계층 구조를 가진, src/lib.rs 내에 모든 정의가 다 들어있는 다른 예제를 봅시다:

communicator
 ├── client
 └── network
     └── client

이 예제에는 또다시 client, network, 그리고 network::client라는 세 개의 모듈이 있습니다. 모듈을 파일로 추출하기 위해 앞서 했던 단계를 따르면, client 모듈을 위한 src/client.rs을 만들게 될 것입니다. network 모듈을 위해서는 src/network.rs 파일을 만들게 될 것입니다. 하지만 network::client 모듈을 src/client.rs로 추출하는 것은 불가능한데, 그 이유는 최상위 층에 client 모듈이 이미 있기 때문이죠! 만일 clientnetwork::client 모듈 둘다 src/client.rs 파일에 집어넣는다면, 러스트는 이 코드가 client를 위한 것인지, 아니면 network::client를 위한 것인지 알아낼 방법이 없을 것입니다.

따라서, network 모듈의 network::client 서브모듈을 위한 파일을 추출하기 위해서는 src/network.rs 파일 대신 network 모듈을 위한 디렉토리를 만들 필요가 있습니다. network 모듈 내의 코드는 그후 src/network/mod.rs 파일로 가고, 서브모듈 network::clientsrc/network/client.rs 파일을 갖게할 수 있습니다. 이제 최상위 층의 src/client.rs는 모호하지 않게 client 모듈이 소유한 코드가 됩니다.

모듈 파일 시스템의 규칙

파일에 관한 모듈의 규칙을 정리해봅시다:

  • 만일 foo라는 이름의 모듈이 서브모듈을 가지고 있지 않다면, foo.rs라는 이름의 파일 내에 foo에 대한 선언을 집어넣어야 합니다.
  • 만일 foo가 서브모듈을 가지고 있다면, foo/mod.rs라는 이름의 파일에 foo에 대한 선언을 집어넣어야 합니다.

이 규칙들은 재귀적으로 적용되므로, foo라는 이름의 모듈이 bar라는 이름의 서브모듈을 갖고 있고 `bar는 서브모듈이 없다면, 여러분의 src 디렉토리 안에는 아래와 같은 파일들이 있어야 합니다:

├── foo
│   ├── bar.rs (contains the declarations in `foo::bar`)
│   └── mod.rs (contains the declarations in `foo`, including `mod bar`)

이 모듈들은 부모 모듈의 파일에 mod 키워드를 사용하여 선언되어 있어야 합니다.

다음으로, pub 키워드에 대해 알아보고 앞의 그 경고들을 없애봅시다!

pub으로 가시성(visibility) 제어하기

우리는 networknetwork::server 코드를 각각 src/network/mod.rssrc/network/server.rs 파일 안으로 이동시켜서 Listing 7-4에 나온 에러 메세지를 해결했습니다. 이 지점에서 cargo build로 프로젝트를 빌드할 수 있긴 했지만, 사용하지 않고 있는 client::connect, network::connect, 그리고 network::server::connect 함수에 대한 경고 메세지를 보게 됩니다:

warning: function is never used: `connect`, #[warn(dead_code)] on by default
src/client.rs:1:1
  |
1 | fn connect() {
  | ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/mod.rs:1:1
  |
1 | fn connect() {
  | ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/server.rs:1:1
  |
1 | fn connect() {
  | ^

그럼 이런 경고들은 왜 나오는 걸까요? 결국, 우리는 우리 자신의 프로젝트 내에서 사용할 필요가 있는 것이 아닌, 사용자가 사용할 수 있도록 만들어진 함수들의 라이브러리를 만드는 중이므로, 이런 connect 함수 등이 사용되지 않는 것은 큰 문제가 아닙니다. 이 함수들을 만든 의도는 함수들이 우리의 지금 이 프로젝트가 아닌 또다른 프로젝트에 사용될 것이란 점입니다.

이 프로그램이 이러한 경고들을 들먹이는 이유를 이해하기 위해, connect 라이브러리 를 다른 프로젝트에서 사용하기를 시도해 봅시다. 이를 위해서, 아래의 코드를 담은 src/main.rs 파일을 만듦으로서 같은 디렉토리에 라이브러리 크레이트와 마찬가지로 바이너리 크레이트를 만들겠습니다:

Filename: src/main.rs

extern crate communicator;

fn main() {
    communicator::client::connect();
}

communicator 라이브러리 크레이트를 가져오기 위해 extern crate 명령어를 사용합니다. 우리의 패키지는 이제 두 개의 크레이트를 담고 있습니다. 카고는 src/main.rs를 바이너리 크레이트의 루트 파일로 취급하는데, 이 바이너리 크레이트는 src/lib.rs가 루트 파일인 이미 있던 라이브러리 크레이트는 별개입니다. 이러한 패턴은 실행 가능한 프로젝트에서 꽤 흔합니다: 대부분의 기능은 라이브러리 크레이트 안에 있고, 바이너리 크레이트는 이 라이브러리 크레이트를 이용합니다. 결과적으로, 다른 프로그램 또한 그 라이브러리 크레이트를 이용할 수 있고, 이는 멋지게 근심을 덜어줍니다.

communicator 라이브러리 밖의 크레이트가 안을 들여다 보는 시점에서, 우리가 만들어왔던 모든 모듈들은 communicator라는 이름을 갖는 모듈 내에 있습니다. 크레이트의 최상위 모듈을 루트 모듈 (root module) 이라 부릅니다.

또한. 비록 우리의 프로젝트의 서브모듈 내에서 외부 크레이트를 이용하고 있을지라도, extern crate이 루트 모듈에 와 있어야 한다는 점(즉 src/main.rs 혹은 src/lib.rs)을 기억하세요. 그러면 서브모듈 안에서 마치 최상위 모듈의 아이템을 참조하듯 외부 크레이트로부터 아이템들을 참조할 수 있습니다.

현시점에서 우리의 바이너리 크레이트는 고작 라이브러리의 client 모듈로부터 connect 함수를 호출할 뿐입니다. 하지만 cargo build을 실행하면 경고들 이후에 에러를 표시할 것입니다:

error: module `client` is private
 --> src/main.rs:4:5
  |
4 |     communicator::client::connect();
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

아하! 이 에러는 client 모듈이 비공개(private) 임을 알려주고 있는데, 이는 그 경고들의 요점입니다. 또한 러스트의 내용 중에서 공개(public) 그리고 비공개(private) 에 대한 개념에 대해 알아보게 될 첫번째 시간입니다. 러스트의 모든 코드의 기본 상태는 비공개입니다: 즉, 다른 사람은 이 코드를 사용할 수 없습니다. 만일 여러분의 프로그램 내에서 비공개 함수를 이용하지 않는다면, 여러분의 프로그램이 그 함수를 이용할 수 있는 유일한 곳이기 때문에, 러스트는 그 함수가 사용된 적이 없다며 경고해줄 것입니다.

client::connect와 같은 함수를 공개로 지정한 뒤에는 우리의 바이너리 크레이트 상에서 이 함수를 호출하는 것이 가능해질 뿐만 아니라, 그 함수가 사용된 적이 없다는 경고 또한 사라질 것입니다. 함수를 공개로 표시하는 것은 러스트로 하여금 그 함수가 우리 프로그램 외부의 코드에 의해 사용될 것이라는 점을 알게끔 해줍니다. 러스트는 이제부터 가능하게 된 이론적인 외부 사용에 대해 이 함수가 “사용되었다”라고 간주합니다. 따라서, 어떤 것이 공개로 표시될 때, 러스트는 그것이 우리 프로그램 내에서 이용되는 것을 요구하지 않으며 해당 아이템이 미사용에 대한 경고를 멈출 것입니다.

함수를 공개로 만들기

러스트에게 어떤 것을 공개하도록 말하기 위해서는, 공개하길 원하는 아이템의 선언 시작 부분에 pub 키워드를 추가합니다. 지금은 client::connect가 사용된 적 없음을 알리는 경고와 바이너리 크레이트에서 나온 module `client` is private 에러를 제거하는데 집중하겠습니다. 아래와 같이 src/lib.rs을 수정하여 client 모듈을 공개로 만드세요:

Filename: src/lib.rs

pub mod client;

mod network;

pub 키워드는 mod 바로 전에 위치합니다. 다시 빌드를 시도해봅시다:

error: function `connect` is private
 --> src/main.rs:4:5
  |
4 |     communicator::client::connect();
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

만세! 다른 에러가 나왔습니다! 네, 다른 에러 메세지라는건 축하할만한 이유죠. 새로운 에러는 function `connect` is private라고 하고 있으므로, src/client.rs를 수정해서 client::connect도 공개로 만듭시다:

Filename: src/client.rs


# #![allow(unused_variables)]
#fn main() {
pub fn connect() {
}
#}

이제 cargo build를 다시 실행하면:

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/mod.rs:1:1
  |
1 | fn connect() {
  | ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/server.rs:1:1
  |
1 | fn connect() {
  | ^

코드가 컴파일되었고, client::connect가 사용된 적 없다는 것에 대한 경고도 사라집니다!

미사용 코드 경고가 항상 여러분의 코드에 있는 아이템이 공개로 만들 필요가 있음을 나타내는 것은 아닙니다: 이 함수들이 여러분의 공개 API의 일부분으로서 들어가길 원하지 않는다면, 미사용 코드 경고는 여러분에게 해당 코드가 더이상 필요 없고 안전하게 지울 수 있음을 알려줄 수 있습니다. 또한 이 경고는 여러분의 라이브러리 내에서 해당 함수가 호출된 모든 곳을 실수로 지웠을 경우 발생할 수 있는 버그를 알려줄 수도 있습니다.

하지만 지금의 경우, 우리는 다른 두 함수들이 우리 크레이트의 공개 API의 일부분이 되길 원하고 있으므로, 이들에게 pub를 표시해줘서 남은 경고들을 제거합시다. src/network/mod.rs를 아래와 같이 수정하세요:

Filename: src/network/mod.rs

pub fn connect() {
}

mod server;

그리고 컴파일하면:

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/mod.rs:1:1
  |
1 | pub fn connect() {
  | ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/server.rs:1:1
  |
1 | fn connect() {
  | ^

흠, network::connectpub으로 설정되어 있음에도, 여전히 미사용 함수 경고가 나옵니다. 그 이유는 함수가 모듈 내에서 공개지만, 함수가 상주해 있는 network 모듈은 공개가 아니기 때문입니다. 이번에는 모듈의 안쪽에서 작업하고 있지만, client::connect에서는 바깥쪽에서 작업을 했었죠. src/lib.rs을 수정하여 network가 공개가 되도록 할 필요가 있습니다. 이렇게요:

Filename: src/lib.rs

pub mod client;

pub mod network;

이제 컴파일하면, 그 경고는 사라집니다:

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/server.rs:1:1
  |
1 | fn connect() {
  | ^

경고 딱 하나 남았네요! 여러분이 직접 고쳐보세요!

비공개 규칙(Privacy Rules)

종합해보면, 아이템 가시성에 관한 규칙은 다음과 같습니다:

  1. 만일 어떤 아이템이 공개라면, 이는 부모 모듈의 어디에서건 접근 가능합니다.
  2. 만일 어떤 아이템이 비공개라면, 같은 파일 내에 있는 부모 모듈 및 이 부모의 자식 모듈에서만 접근 가능합니다.

비공개 예제(Privacy Examples)

연습을 위해 몇 가지 비공개에 관한 예제를 봅시다. 새로운 라이브러리 프로젝트를 만들고 이 새로운 프로젝트의 src/lib.rs에 Listing 7-5와 같이 코드를 넣으세요:

Filename: src/lib.rs

mod outermost {
    pub fn middle_function() {}

    fn middle_secret_function() {}

    mod inside {
        pub fn inner_function() {}

        fn secret_function() {}
    }
}

fn try_me() {
    outermost::middle_function();
    outermost::middle_secret_function();
    outermost::inside::inner_function();
    outermost::inside::secret_function();
}

Listing 7-5: 비공개 및 공개 함수 예제. 몇 가지는 잘못되었음.

이 코드를 컴파일하기 전에, try_me 함수의 어떤 라인이 에러를 발생시킬지 추측해보세요. 그리고나서 컴파일을 하여 여러분이 맞았는지 확인하고, 에러에 대한 논의를 위해 계속 읽어주세요!

에러 보기

try_me 함수는 우리 프로젝트의 루트 모듈 내에 있습니다. outermost 모듈은 비공개지만, 두 번째 비공개 규칙은 try_me함수가 outermost 모듈에 접근하는 것이 허용됨을 알려주는데, 이는 outermosttry_me 함수와 마찬가지로 현재의 (루트) 모듈 내에 있기 때문입니다.

middle_function이 공개이므로 outermost::middle_function 호출은 작동할 것이며, try_memiddle_function의 부모 모듈인 outermost를 통해 middle_function에 접근하고 있습니다. 이 모듈에 접근 가능하하는 것은 이전 문단에서 알아냈죠.

outermost::middle_secret_function 호출은 컴파일 에러를 일으킬 것입니다. middle_secret_function는 비공개이므로, 두번째 규칙이 적용됩니다. 루트 모듈은 middle_secret_function의 현재 모듈도 아니고 (outermost가 현재 모듈입니다), middle_secret_function의 현재 모듈의 자식 모듈도 아닙니다.

inside 모듈은 비공개고 자식 모듈이 없으므로, 이것의 현재 모듈인 outermost에 의해서만 접근될 수 있습니다. 이는 즉 try_me 함수는 outermost::inside::inner_functionoutermost::inside::secret_function를 호출할 수 없음을 의미합니다.

에러 고치기

여기 이 에러들을 고치기 위해 코드를 수정하는것에 관한 몇 가지 제안이 있습니다. 각각을 시도해보기 전에, 이 시도가 에러를 고칠지 그렇지 않을지 추측해 보고, 컴파일을 해서 여러분이 맞췄는지 그렇지 않은지 확인하고, 왜 그랬는지 이해하기 위해 비공개 규칙을 이용해보세요.

  • inside 모듈이 공개라면 어떨까요?
  • outermost가 공개고 inside가 비공개면 어떨까요?
  • inner_function의 내부에서 ::outermost::middle_secret_function()을 호출한다면 어떨까요? (시작 부분의 콜론 두개는 루트 모듈로부터 시작하여 모듈을 참조하고 싶음을 나타냅니다)

자유롭게 더 많은 실험을 설계하고 시도해 보세요!

다음으로, use 키워드를 사용하여 아이템을 스코프 내로 가져오는 것에 대해 이야기해 봅시다.

이름 가져오기 (Importing Names)

우리는 Listing 7-6에서 보시는 것과 같이 nested_modules 함수를 호출하는 것처럼, 모듈 이름을 호출 구문의 일부분으로 사용하여 해당 모듈 내에 정의된 함수를 호출하는 방법을 다룬바 있습니다:

Filename: src/main.rs

pub mod a {
    pub mod series {
        pub mod of {
            pub fn nested_modules() {}
        }
    }
}

fn main() {
    a::series::of::nested_modules();
}

Listing 7-6: 함수에 인접한 모듈 경로를 완전히 특정한 함수 호출하기

보시다시피 완전하게 경로를 지정한 이름을 참조하는 것은 너무 길어질 수 있습니다. 다행히도 러스트는 이러한 호출을 더 간결하게 만들어주는 키워드를 가지고 있습니다.

use를 이용한 간결한 가져오기

러스트의 use 키워드는 여러분이 스코프 내에서 호출하고 싶어하는 함수의 모듈을 가져옴으로써 긴 함수 호출을 줄여줍니다. a::series::of 모듈을 바이너리 크레이트의 루트 스코프로 가져온 예제입니다:

Filename: src/main.rs

pub mod a {
    pub mod series {
        pub mod of {
            pub fn nested_modules() {}
        }
    }
}

use a::series::of;

fn main() {
    of::nested_modules();
}

use a::series::of; 줄은 of 모듈을 참조하고 싶은 곳마다 a::series::of 전부를 사용하기 보다는 of를 사용할 수 있다는 뜻입니다.

use 키워드는 우리가 명시한 것만 스코프 내로 가져옵니다: 즉 모듈의 자식들을 스코프 내로 가져오지는 않습니다. 이는 nested_modules 함수를 호출하고자 할 떄 여전히 of::nested_modules를 사용해야 하는 이유입니다.

다음과 같이 use 구문 안에서 모듈 대신 함수를 명시하여 스코프 내에서 함수를 가져올 수도 있습니다:

pub mod a {
    pub mod series {
        pub mod of {
            pub fn nested_modules() {}
        }
    }
}

use a::series::of::nested_modules;

fn main() {
    nested_modules();
}

이렇게 하면 모든 모듈을 안 써주고 함수를 직접 참조하도록 해줍니다.

열거형 또한 모듈과 비슷한 일종의 이름공간을 형성하고 있기 때문에, 열거형의 variant 또한 use를 이용하여 가져올 수 있습니다. 어떠한 use 구문이건 하나의 이름공간으로부터 여러 개의 아이템을 가져오려 한다면, 여러분은 아래와 같이 중괄호와 쉼표를 구문의 마지막 위치에 사용하여 이 아이템들을 나열할 수 있습니다:

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

use TrafficLight::{Red, Yellow};

fn main() {
    let red = Red;
    let yellow = Yellow;
    let green = TrafficLight::Green;
}

Green variant에 대해서는 여전히 TrafficLight 이름공간을 명시하고 있는데, 이는 use 구문 내에 Green를 포함하지 않았기 때문입니다.

*를 이용한 모두(glob) 가져오기

이름공간 내의 모든 아이템을 가져오기 위해서는 * 문법을 이용할 수 있습니다. 예를 들면:

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

use TrafficLight::*;

fn main() {
    let red = Red;
    let yellow = Yellow;
    let green = Green;
}

*글롭(glob) 이라고 부르며, 이는 이름공간 내에 공개된 모든 아이템을 가져올 것입니다. 여러분은 글롭을 아껴가며 써야 합니다: 글롭은 편리하지만, 여러분이 예상한 것보다 더 많은 아이템을 끌어와서 이름 간의 충돌(naming conflict)의 원인이 될수도 있습니다.

super를 사용하여 부모 모듈에 접근하기

이 장의 시작 부분에서 보셨듯이, 여러분이 라이브러리 크레이트를 만들때, 카고는 여러분들을 위해 tests 모듈을 만들어줍니다. 지금부터 이에 대한 구체적인 부분들을 봅시다. 여러분의 communicator 프로젝트 내에 있는 src/lib.rs을 여세요:

Filename: src/lib.rs

pub mod client;

pub mod network;

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
    }
}

11장에서 테스트에 관한 더 많은걸 설명하고 있습니다만, 이 예제는 지금도 이해가 되시리라 봅니다: tests라는 이름의 모듈이 우리의 다른 모듈들 옆에 있고, it_works라는 이름의 함수 하나를 담고 있지요. 좀 특별한 주석(annotation)이 있지만, tests 모듈을 그냥 또다른 모듈일 뿐입니다! 따라서 우리의 모듈 계층 구조는 아래와 같이 생겼습니다:

communicator
 ├── client
 ├── network
 |   └── client
 └── tests

테스트는 우리 라이브러리 내에 있는 코드를 연습하기 위한 것이므로, 현재로서는 어떠한 기능도 확인할 게 없긴 하지만, it_works 함수 안에서 우리의 client::connect 함수를 호출해 봅시다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        client::connect();
    }
}
#}

cargo test 명령을 써서 테스트를 실행하면:

$ cargo test
   Compiling communicator v0.1.0 (file:///projects/communicator)
error[E0433]: failed to resolve. Use of undeclared type or module `client`
 --> src/lib.rs:9:9
  |
9 |         client::connect();
  |         ^^^^^^^^^^^^^^^ Use of undeclared type or module `client`

컴파일이 실패했습니다, 하지만 대체 왜일까요? 우리는 src/main.rs에서 했었던 것과 마찬가지로 함수 앞에 communicator::를 붙일 필요가 없는데, 왜냐하면 이 코드가 분명히 communicator 라이브러리 크레이트 안에 있기 때문입니다. 원인은 경로가 항상 현재 모듈을 기준으로 상대적인데, 여기는 test이기 때문입니다. 딱 하나의 예외는 use 구문인데, 이는 기본적으로 크레이트 루트에 대한 상대적인 경로로 인식됩니다. 우리의 tests 모듈은 이 스코프 내에서 client 모듈이 필요합니다!

그러면 어떻게 모듈 계층 구조 내에서 한 모듈 위로 거슬러 올라가 tests 모듈 안에서 client::connect 함수를 호출할 수 있을까요? 아래와 같이 앞에 콜론 두개를 사용하여 러스트에게 우리가 루트부터 시작하여 전체 경로를 나열하겠다고 알려주는 방법이 있습니다:

::client::connect();

혹은, 아래와 같이 super를 사용하여 계층 구조 상에서 현재 모듈로부터 한 모듈 거슬러 올라갈 수도 있습니다:

super::client::connect();

이 두 가지 옵션은 이번 예제에서는 차이가 없는 것처럼 보이지만, 여러분의 모듈 계층 구조가 깊어진다면, 매번 루트에서부터 경로를 시작하는 것은 여러분의 코드를 길게 만들 것입니다. 그런 경우에는 super를 이용하여 현재 모듈에서 형제 모듈을 가져오는 것이 좋은 지름길이 됩니다. 여기에 더해서, 만약 여러분이 여러 군데에 루트로부터 시작되는 경로를 명시한 뒤에 서브트리를 다른 곳으로 옮기는 식으로 여러분의 모듈을 재정리한다면, 여러분은 여러 군데의 경로를 갱신하도록 요구받는 처지가 될 것이고, 이는 지루한 작업이 될 것입니다.

각각의 테스트에 super::를 타이핑해야 하는 것이 짜증날수 있겠지만, 여러분은 이미 여기에 대한 해답이 될 도구를 보셨습니다: use 말이죠! super::의 기능은 use에 제공한 경로를 변경시켜서 이제 루트 모듈 대신 부모 모듈에 상대적인 경로가 되게 해줍니다.

이러한 이유로, 특히 tests 모듈 내에서는 보통 use super::something이 가장 좋은 해결책이 됩니다. 따라서 이제 우리의 테스트는 이렇게 됩니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[cfg(test)]
mod tests {
    use super::client;

    #[test]
    fn it_works() {
        client::connect();
    }
}
#}

cargo test를 다시 실행시키면, 테스트가 통과되고 테스트 결과 출력의 첫번째 부분이 아래와 같이 나타날 것입니다:

$ cargo test
   Compiling communicator v0.1.0 (file:///projects/communicator)
     Running target/debug/communicator-92007ddb5330fa5a

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

정리

이제 여러분은 코드를 조직화하기 위한 몇가지 새로운 기술을 알게 되었습니다! 관련된 기능들을 함께 묶여주는 이 기술들을 사용하고, 파일들이 너무 길어지지 않게 하고, 여러분의 라이브러리 사용자들에게 깔끔한 공개 API를 제공해 보세요.

다음으로 여러분의 멋지고 깔끔한 코드에 사용할 수 있는 표준 라이브러리 내의 몇가지 컬렉션 데이터 구조를 보겠습니다!

일반적인 컬렉션

러스트의 표준 라이브러리에는 컬렉션이라 불리는 여러 개의 매우 유용한 데이터 구조들이 포함되어 있습니다. 대부분의 다른 데이터 타입들은 하나의 특정한 값을 나타내지만, 컬렉션은 다수의 값을 담을 수 있습니다. 내장된 배열(build-in array)와 튜플 타입과는 달리, 이 컬렉션들이 가리키고 있는 데이터들은 힙에 저장되는데, 이는 즉 데이터량이 컴파일 타임에 결정되지 않아도 되며 프로그램이 실행될 때 늘어나거나 줄어들 수 있다는 의미입니다. 각각의 컬렉션 종류는 서로 다른 용량과 비용을 가지고 있으며, 여러분의 현재 상황에 따라 적절한 컬렉션을 선택하는 것은 시간이 지남에 따라 발전시켜야 할 기술입니다. 이번 장에서는 러스트 프로그램에서 굉장히 자주 사용되는 세 가지 컬렉션에 대해 논의해 보겠습니다:

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

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

이제부터 어떻게 벡터, 스트링, 해쉬맵을 만들고 업데이트하는지 뿐만 아니라 어떤 것이 각각의 컬렉션을 특별하게 해주는지에 대해 논의해 보겠습니다.

벡터

우리가 보게될 첫번째 콜렉션은 벡터라고도 알려진 Vec<T>입니다. 벡터는 메모리 상에 서로 이웃하도록 모든 값을 집어넣는 단일 데이터 구조 안에 하나 이상의 값을 저장하도록 해줍니다. 벡터는 같은 타입의 값만을 저장할 수 있습니다. 이는 여러분이 파일 내의 텍스트의 라인들이라던가 장바구니의 아이템 가격들 같은 아이템 리스트를 저장하는 상황일 경우 유용합니다.

새 벡터 만들기

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


# #![allow(unused_variables)]
#fn main() {
let v: Vec<i32> = Vec::new();
#}

Listing 8-1: i32 타입의 값을 가질 수 있는 비어있는 새 벡터 생성

여기에 타입 명시(type annotation)를 추가한 것을 주목하세요. 이 벡터에 어떠한 값도 집어넣지 않았기 때문에, 러스트는 우리가 저장하고자 하는 요소의 종류가 어떤 것인지 알지 못합니다. 이는 중요한 지점입니다. 벡터는 제네릭(generic)을 이용하여 구현되었습니다; 제네릭을 이용하여 여러분만의 타입을 만드는 방법은 10장에서 다룰 것입니다. 지금 당장은, 표준 라이브러리가 제공하는 Vec타입은 어떠한 종류의 값이라도 저장할 수 있다는 것, 그리고 특정한 벡터는 특정한 타입의 값을 저장할 때, 이 타입은 꺾쇠 괄호(<>) 안에 적는다는 것만 알아두세요. Listing 8-1에서는 러스트에게 v 안의 Veci32 타입의 요소를 가질 것이고 알려주었습니다.

일단 우리가 값을 집어넣으면 러스트는 우리가 저장하고자 하는 값의 타입을 대부분 유추할 수 있으므로, 좀 더 현실적인 코드에서는 이러한 타입 명시를 할 필요가 거의 없습니다. 초기값들을 갖고 있는 Vec<T>을 생성하는 것이 더 일반적이며, 러스트는 편의를 위해 vec! 매크로를 제공합니다. 이 매크로는 우리가 준 값들을 저장하고 있는 새로운 Vec을 생성합니다. Listing 8-2는 1, 2, 3을 저장하고 있는 새로운 Vec<i32>을 생성할 것입니다:


# #![allow(unused_variables)]
#fn main() {
let v = vec![1, 2, 3];
#}

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

초기 i32 값들을 제공했기 때문에, 러스트는 v가 `Vec 타입이라는 것을 유추할 수 있으며, 그래서 타입 명시는 필요치 않습니다. 다음은, 벡터를 어떻게 수정하는지를 살펴보겠습니다.

벡터 갱신하기

벡터를 만들고 여기에 요소들을 추가하기 위해서는 아래 Listing 8-3과 같이 push 메소드를 사용할 수 있습니다:


# #![allow(unused_variables)]
#fn main() {
let mut v = Vec::new();

v.push(5);
v.push(6);
v.push(7);
v.push(8);
#}

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

3장에서 설명한 바와 같이, 어떤 변수에 대해 그 변수가 담고 있는 값이 변경될 수 있도록 하려면, mut 키워드를 사용하여 해당 변수를 가변으로 만들어 줄 필요가 있습니다. 우리가 집어넣는 숫자는 모두 i32 타입이며, 러스트는 데이터로부터 이 타입을 추론하므로, 우리는 Vec<i32> 명시를 붙일 필요가 없습니다.

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

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


# #![allow(unused_variables)]
#fn main() {
{
    let v = vec![1, 2, 3, 4];

    // v를 가지고 뭔가 합니다

} // <- v가 스코프 밖으로 벗어났고, 여기서 해제됩니다
#}

Listing 8-4: 벡터와 벡터의 요소들이 드롭되는 곳을 보여주기

벡터가 드롭될 때 벡터의 내용물 또한 전부 드롭되는데, 이는 벡터가 가지고 있는 정수들이 모두 제거된다는 의미입니다. 이는 직관적인 것처럼 보일 수도 있겠지만 벡터의 요소들에 대한 참조자를 만들때는 좀 더 복잡해 질 수 있습니다. 다음으로 이런 상황을 파해쳐 봅시다!

벡터의 요소들 읽기

지금까지 벡터를 만들고, 갱신하고, 없애는 방법에 대해 알아보았으니, 벡터의 내용물을 읽어들이는 방법을 알아보는 것이 다음 단계로 좋아보입니다. 벡터 내에 저장된 값을 참조하는 두 가지 방법이 있습니다. 예제에서는 특별히 더 명료하게 하기 위해 함수들이 반환하는 값의 타입을 명시했습니다.

Listing 8-5는 인덱스 문법이나 get 메소드를 가지고 벡터의 값에 접근하는 두 방법 모두를 보여주고 있습니다:


# #![allow(unused_variables)]
#fn main() {
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>를 얻은 것입니다.

러스트가 벡터 요소를 참조하는 두가지 방법을 제공하는 이유는 여러분이 벡터가 가지고 있지 않은 인덱스값을 사용하고자 했을 때 프로그램이 어떻게 동작할 것인지 여러분이 선택할 수 있도록 하기 위해서입니다. 예를 들어, 아래의 Listing 8-6과 같이 5개의 요소를 가지고 있는 벡터가 있고 100 인덱스에 있는 요소에 접근하려고 시도한 경우 프로그램은 어떻게 동작해야 할까요:


# #![allow(unused_variables)]
#fn main() {
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에 대해 다루는 로직을 갖추어야 합니다. 예를 들어 인덱스는 사람이 직접 번호를 입력하는 것으로 들어올 수도 있습니다. 만일 사용자가 잘못하여 너무 큰 숫자를 입력하여 프로그램이 None 값을 받았을 경우, 여러분은 사용자에게 현재 Vec에 몇개의 아이템이 있으며 유효한 값을 입력할 또한번의 기회를 줄 수도 있습니다. 이런 편이 오타 때문에 프로그램이 죽는 것 보다는 더 사용자 친화적이겠죠!

유효하지 않은 참조자

프로그램이 유효한 참조자를 얻을 때, 빌림 검사기(borrow checker)가 (4장에서 다루었던) 소유권 및 빌림 규칙을 집행하여 이 참조자와 벡터의 내용물로부터 얻은 다른 참조자들이 계속 유효하게 남아있도록 확실히 해줍니다. 같은 스코프 내에서 가변 참조자와 불변 참조자를 가질 수 없다는 규칙을 상기하세요. 이 규칙은 아래 예제에서도 적용되는데, Listing 8-7에서는 벡터의 첫번째 요소에 대한 불변 참조자를 얻은 뒤 벡터의 끝에 요소를 추가하고자 했습니다:

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

let first = &v[0];

v.push(6);

Listing 8-7: 아이템에 대한 참조자를 가지는 동안 벡터에 요소 추가 시도하기

이 예제를 컴파일하면 아래와 같은 에러가 발생합니다:

error[E0502]: cannot borrow `v` as mutable because it is also borrowed as
immutable
  |
4 | let first = &v[0];
  |              - immutable borrow occurs here
5 |
6 | v.push(6);
  | ^ mutable borrow occurs here
7 | }
  | - immutable borrow ends here

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

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

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

만일 벡터 내의 각 요소들을 차례대로 접근하고 싶다면, 하나의 값에 접근하기 위해 인덱스를 사용하는것 보다는, 모든 요소들에 대해 반복처리를 할 수 있습니다. Listing 8-8은 for 루프를 사용하여 i32의 벡터 내에 있는 각 요소들에 대한 불변 참조자를 얻어서 이를 출력하는 방법을 보여줍니다:


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

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

만일 모든 요소들을 변형시키길 원한다면 가변 벡터 내의 각 요소에 대한 가변 참조자로 반복작업을 할 수도 있습니다. Listing 8-9의 for 루프는 각 요소에 50을 더할 것입니다:


# #![allow(unused_variables)]
#fn main() {
let mut v = vec![100, 32, 57];
for i in &mut v {
    *i += 50;
}
#}

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

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

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

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

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


# #![allow(unused_variables)]
#fn main() {
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으로 넘어갑시다!

스트링

4장에서 스트링에 관한 이야기를 했습니다만, 지금은 좀 더 깊이 살펴보겠습니다. 새로운 러스트인들은 흔히들 스트링 부분에서 막히는데 이는 세 가지 개념의 조합으로 인한 것입니다: 가능한 에러를 꼭 노출하도록 하는 러스트의 성향, 많은 프로그래머의 예상보다 더 복잡한 데이터 구조인 스트링, 그리고 UTF-8입니다. 다른 언어들을 사용하다 왔을 때 이 개념들의 조합이 러스트의 스트링을 어려운 것처럼 보이게 합니다.

스트링이 컬렉션 장에 있는 이유는 스트링이 바이트의 컬렉션 및 이 바이트들을 텍스트로 통역할때 유용한 기능을 제공하는 몇몇 메소드로 구현되어 있기 때문입니다. 이번 절에서는 생성, 갱신, 값 읽기와 같은 모든 컬렉션 타입이 가지고 있는, String에서의 연산에 대해 이야기 해보겠습니다. 또한 String을 다른 컬렉션들과 다르게 만드는 부분, 즉 사람과 컴퓨터가 String 데이터를 통역하는 방식 간의 차이로 인해 생기는 String 인덱싱의 복잡함을 논의해보겠습니다.

스트링이 뭔가요?

먼저 스트링이라는 용어가 정확히 무엇을 뜻하는 것인지 정의해보겠습니다. 러스트는 핵심 언어 기능 내에서 딱 한가지 스트링 타입만 제공하는데, 이는 바로 스트링 슬라이스인 str이고, 이것의 참조자 형태인 &str을 많이 봤죠. 4장에서는 스트링 슬라이스에 대해 얘기했고, 이는 다른 어딘가에 저장된 UTF-8로 인코딩된 스트링 데이터의 참조자입니다. 예를 들어, 스트링 리터럴은 프로그램의 바이너리 출력물 내에 저장되어 있으며, 그러므로 스트링 슬라이스입니다.

String 타입은 핵심 언어 기능 내에 구현된 것이 아니고 러스트의 표준 라이브러리를 통해 제공되며, 커질 수 있고, 가변적이며, 소유권을 갖고 있고, UTF-8로 인코딩된 스트링 타입입니다. 러스트인들이 “스트링”에 대해 이야기할 때, 그들은 보통 String과 스트링 슬라이스 &str 타입 둘 모두를 이야기한 것이지, 이들 중 하나를 뜻한 것은 아닙니다. 이번 절은 대부분 String에 관한 것이지만, 두 타입 모두 러스트 표준 라이브러리에서 매우 많이 사용되며 String과 스트링 슬라이스 모두 UTF-8로 인코딩되어 있습니다.

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

새로운 스트링 생성하기

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


# #![allow(unused_variables)]
#fn main() {
let mut s = String::new();
#}

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

이 라인은 우리가 어떤 데이터를 담아둘 수 있는 s라는 빈 스트링을 만들어 줍니다. 종종 우리는 스트링에 담아두고 시작할 초기값을 가지고 있을 것입니다. 그런 경우, to_string 메소드를 이용하는데, 이는 Display 트레잇이 구현된 어떤 타입이든 사용 가능하며, 스트링 리터럴도 이 트레잇을 구현하고 있습니다. Listing 8-12에서 두 가지 예제를 보여주고 있습니다:


# #![allow(unused_variables)]
#fn main() {
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의 코드와 동일합니다:


# #![allow(unused_variables)]
#fn main() {
let s = String::from("initial contents");
#}

Listing 8-13: String::from 함수를 사용하여 스트링 리터럴로부터 String 생성하기

스트링이 너무나 많은 것들에 사용되기 때문에, 스트링을 위해 다양한 제네릭 API들을 사용할 수 있으며, 다양한 옵션들을 제공합니다. 몇몇은 쓸모없는 것처럼 느껴질 수도 있지만, 다 사용할 곳이 있습니다! 지금의 경우, String::from.to_string은 정확히 똑같은 일을 하며, 따라서 어떤 것을 사용하는가는 여러분의 스타일에 따라 달린 문제입니다.

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


# #![allow(unused_variables)]
#fn main() {
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");
#}

Listing 8-14: 스트링에 다양한 언어로 인삿말 저장하기

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

스트링 갱신하기

String은 크기가 커질 수 있으며 이것이 담고 있는 내용물은 Vec의 내용물과 마찬가지로 더 많은 데이터를 집어넣음으로써 변경될 수 있습니다. 추가적으로, + 연산자나 format! 매크로를 사용하여 편리하게 String 값들을 서로 접합(concatenation)할 수 있습니다.

push_strpush를 이용하여 스트링 추가하기

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


# #![allow(unused_variables)]
#fn main() {
let mut s = String::from("foo");
s.push_str("bar");
#}

Listing 8-15: push_str 메소드를 사용하여 String에 스트링 슬라이스 추가하기

s는 위의 두 라인 뒤에 “foobar”를 담게 될 것입니다. push_str 메소드는 스트링 슬라이스를 파라미터로 갖는데 이는 파라미터의 소유권을 가져올 필요가 없기 때문입니다. 예를 들어, Listing 8-16의 코드는 s1s2의 내용물을 추가한 뒤 s2를 더 이상 쓸 수 없게 되었다면 불행했을 경우를 보여주고 있습니다:


# #![allow(unused_variables)]
#fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(&s2);
println!("s2 is {}", s2);
#}

Listing 8-16: 스트링 슬라이스를 String에 붙인 이후에 스트링 슬라이스를 사용하기

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

push 메소드는 한 개의 글자를 파라미터로 받아서 String에 추가합니다. Listing 8-17은 push 메소드를 사용하여 String에 l을 추가하는 코드를 보여주고 있습니다:


# #![allow(unused_variables)]
#fn main() {
let mut s = String::from("lo");
s.push('l');
#}

Listing 8-17: push를 사용하여 String 값에 한 글자 추가하기

위의 코드를 실행한 결과로 slol을 담고 있을 것입니다.

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

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


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

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

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

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

이는 표준 라이브러리에 있는 정확한 시그니처는 아닙니다: 표준 라이브러리 내에서 add는 제네릭을 이용하여 정의되어 있습니다. 여기서는 제네릭에 구체 타입(concrete type)을 대입한 add의 시그니처를 보는 중인데, 이는 우리가 String 값으로 이 메소드를 호출했을때 생깁니다. 제네릭에 대한 내용은 10장에서 다룰 것입니다. 이 시그니처는 교묘한 + 연산자를 이해하는데 필요한 단서를 줍니다.

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

두번째로, 시그니처에서 addself의 소유권을 가져가는 것을 볼 수 있는데, 이는 self&안 가지고 있기 때문입니다. 즉 Listing 8-18의 예제에서 s1add 호출로 이동되어 이후에는 더 이상 유효하지 않을 것이라는 의미입니다. 따라서 let s3 = s1 + &s2;가 마치 두 스트링을 복사하여 새로운 스트링을 만들 것처럼 보일지라도, 실제로 이 구문은 s1의 소유권을 가져다가 s2의 내용물의 복사본을 추가한 다음, 결과물의 소유권을 반환합니다. 달리 말하면, 이 구문은 여러 복사본을 만드는 것처럼 보여도 그렇지 않습니다: 이러한 구현은 복사보다 더 효율적입니다.

만일 여러 스트링을 접하고자 한다면, +의 동작은 다루기 불편해 집니다.:


# #![allow(unused_variables)]
#fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = s1 + "-" + &s2 + "-" + &s3;
#}

이 지점에서 stic-tac-toe가 될 것입니다. 모든 +" 문자들과 함께 보면 어떤 결과가 나올지 알기 어렵습니다. 더 복잡한 스트링 조합을 위해서는 format! 매크로를 사용할 수 있습니다:


# #![allow(unused_variables)]
#fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

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

이 코드 또한 stic-tac-toe을 설정합니다. format! 매크로는 println!과 똑같은 방식으로 작동하지만, 스크린에 결과를 출력하는 대신 결과를 담은 String을 반환해줍니다. format!을 이용한 버전이 훨씬 읽기 쉽고, 또한 어떠한 파라미터들의 소유권도 가져가지 않습니다.

스트링 내부의 인덱싱

다른 많은 프로그래밍 언어들에서, 인덱스를 이용한 참조를 통해 스트링 내부의 개별 문자들에 접근하는 것은 유효하고 범용적인 연산에 속합니다. 그러나 러스트에서 인덱싱 문법을 이용하여 String의 부분에 접근하고자 하면 에러를 얻게 됩니다. 아래 Listing 8-19와 같은 코드를 생각해봅시다:

let s1 = String::from("hello");
let h = s1[0];

Listing 8-19: 스트링에 인덱싱 문법을 사용하는 시도

이 코드는 아래와 같은 에러를 출력합니다:

error: the trait bound `std::string::String: std::ops::Index<_>` is not
satisfied [--explain E0277]
  |>
  |>     let h = s1[0];
  |>             ^^^^^
note: the type `std::string::String` cannot be indexed by `_`

에러와 노트 부분이 이야기해 줍니다: 러스트 스트링은 인덱싱을 지원하지 않는다고. 그렇지만 왜 안되는 걸까요? 이 질문에 답하기 위해서는 러스트가 어떻게 스트링을 메모리에 저장하는지에 관하여 살짝 이야기해야 합니다.

내부적 표현

StringVec<u8>을 감싼 것입니다(wrapper). Listing 8-14에서 보았던 몇가지 적절히 인코딩된 UTF-8 예제 스트링을 살펴봅시다. 첫번째로, 이것입니다:


# #![allow(unused_variables)]
#fn main() {
let len = String::from("Hola").len();
#}

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


# #![allow(unused_variables)]
#fn main() {
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을 가지고 그러한 성능을 보장하는 것은 불가능한데, 그 이유는 러스트가 스트링 내에 얼마나 많은 유효 문자가 있는지 알아내기 위해 내용물의 시작 지점부터 인덱스로 지정된 곳까지 훑어야 하기 때문입니다.

스트링 슬라이싱하기

스트링 인덱싱의 리턴 타입이 어떤 것이 (바이트 값인지, 캐릭터인지, 문자소 클러스터인지, 혹은 스트링 슬라이스인지) 되어야 하는지 명확하지 않기 때문에 스트링의 인덱싱은 종종 나쁜 아이디어가 됩니다. 따라서, 여러분이 스트링 슬라이스를 만들기 위해 정말로 인덱스를 사용하고자 한다면 러스트는 좀 더 구체적으로 지정하도록 요청합니다. 여러분의 인덱싱을 더 구체적으로 하고 스트링 슬라이스를 원한다는 것을 가리키기 위해서, []에 숫자 하나를 사용하는 인덱싱보다, []와 범위를 사용하여 특정 바이트들이 담고 있는 스트링 슬라이스를 만들 수 있습니다:


# #![allow(unused_variables)]
#fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
#}

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

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

thread 'main' panicked at 'index 0 and/or 1 in `Здравствуйте` do not lie on
character boundary', ../src/libcore/str/mod.rs:1694

여러분은 스트링 슬라이스를 만들기 위하여 범위를 이용하는 방법을 조심스럽게 사용해야 하는데, 이는 여러분의 프로그램을 죽게 만들 수도 있기 때문입니다.

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

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

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


# #![allow(unused_variables)]
#fn main() {
for c in "नमस्ते".chars() {
    println!("{}", c);
}
#}

이 코드는 다음을 출력할 것입니다:

न
म
स
्
त
े

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


# #![allow(unused_variables)]
#fn main() {
for b in "नमस्ते".bytes() {
    println!("{}", b);
}
#}

이 코드는 이 String을 구성하는 아래처럼 시작되는 18 바이트를 출력합니다:

224
164
168
224
// ... etc

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

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

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

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

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

해쉬맵(hash map)

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

해쉬맵은 여러분이 벡터를 이용하듯 인덱스를 이용하는 것이 아니라 임의의 타입으로 된 키를 이용하여 데이터를 찾기를 원할때 유용합니다. 예를 들면, 게임 상에서는 각 팀의 점수를 해쉬맵에 유지할 수 있는데, 여기서 키는 팀의 이름이고 값은 팀의 점수가 될 수 있습니다. 팀의 이름을 주면, 여러분은 그 팀의 점수를 찾을 수 있습니다.

이 장에서는 해쉬맵의 기본 API를 다룰 것이지만, 표준 라이브러리의 HashMap에 정의되어 있는 함수 중에는 더 좋은 것들이 숨어있습니다. 항상 말했듯이, 더 많은 정보를 원하신다면 표준 라이브러리 문서를 확인하세요.

새로운 해쉬맵 생성하기

우리는 빈 해쉬맵을 new로 생성할 수 있고, insert를 이용하여 요소를 추가할 수 있습니다. Listing 8-20에서, 우리는 팀 이름이 각각 블루(Blue)와 옐로우(Yellow)인 두 팀의 점수를 유지하고 있습니다. 블루 팀은 10점, 옐로우 팀은 50점으로 시작할 것입니다:


# #![allow(unused_variables)]
#fn main() {
use std::collections::HashMap;

let mut scores = HashMap::new();

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

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

먼저 표준 라이브러리의 컬렉션 부분으로부터 HashMapuse로 가져와야할 필요가 있음을 주목하세요. 우리가 보고 있는 세 가지 일반적인 컬렉션 중에서 이 해쉬맵이 제일 덜 자주 사용되는 것이기 때문에, 프렐루드(prelude) 내에 자동으로 가져와지는 기능에 포함되어 있지 않습니다. 또한 해쉬맵은 표준 라이브러리로부터 덜 지원을 받습니다; 예를 들면 해쉬맵을 생성하는 빌트인 매크로가 없습니다.

벡터와 마찬가지로, 해쉬맵도 데이터를 힙에 저장합니다. 이 HashMapString 타입의 키와 i32 타입의 값을 갖습니다. 벡터와 비슷하게 해쉬맵도 동질적입니다: 모든 키는 같은 타입이어야 하고, 모든 값도 같은 타입이여야 합니다.

해쉬맵을 생성하는 또다른 방법은 튜플의 벡터에 대해 collect 메소드를 사용하는 것인데, 이 벡터의 각 튜플은 키와 키에 대한 값으로 구성되어 있습니다. collect 메소드는 데이터를 모아서 HashMap을 포함한 여러 컬렉션 타입으로 만들어줍니다. 예를 들면, 만약 두 개의 분리된 벡터에 각각 팀 이름과 초기 점수를 갖고 있다면, 우리는 zip 메소드를 이용하여 “Blue”와 10이 한 쌍이 되는 식으로 튜플의 벡터를 생성할 수 있습니다. 그 다음 Listing 8-21과 같이 collect 메소드를 사용하여 튜플의 벡터를 HashMap으로 바꿀 수 있습니다:


# #![allow(unused_variables)]
#fn main() {
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와 같이 값들이 이동되어 해쉬맵이 그 값들에 대한 소유자가 될 것입니다:


# #![allow(unused_variables)]
#fn main() {
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_namefield_value를 해쉬맵으로 이동시킨 후에는 더 이상 이 둘을 사용할 수 없습니다.

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

해쉬맵 내의 값 접근하기

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


# #![allow(unused_variables)]
#fn main() {
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);
#}

Listing 8-23: 해쉬맵 내에 저장된 블루 팀의 점수 접근하기

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

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


# #![allow(unused_variables)]
#fn main() {
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를 두 번 호출함에도, 해쉬맵은 딱 하나의 키/값 쌍을 담게 될 것인데 그 이유는 두 번 모두 블루 팀의 키에 대한 값을 삽입하고 있기 때문입니다:


# #![allow(unused_variables)]
#fn main() {
use std::collections::HashMap;

let mut scores = HashMap::new();

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

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

Listing 8-24: 특정한 키로 저장된 값을 덮어쓰기

이 코드는 {"Blue": 25}를 출력할 것입니다. 원래의 값 10은 덮어써졌습니다.

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

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


# #![allow(unused_variables)]
#fn main() {
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-25의 코드를 실행하면 {"Yellow": 50, "Blue": 10}를 출력할 것입니다. 첫번째 entry 호출은 옐로우 팀에 대한 키에 대하여 값 50을 삽입하는데, 이는 옐로우 팀이 값을 가지고 있지 않기 떄문입니다. 두번째 entry 호출은 해쉬맵을 변경하지 않는데, 왜냐하면 블루 팀은 이미 값 10을 가지고 있기 때문입니다.

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

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


# #![allow(unused_variables)]
#fn main() {
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 문서는 이 연습문제들에 대해 도움이 될만한 벡터, 스트링, 그리고 해쉬맵의 메소드들을 설명해줍니다!

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

에러 처리

러스트의 신뢰성에 대한 약속은 에러 처리에도 확장되어 있습니다. 에러는 소프트웨어에서 피할 수 없는 현실이며, 따라서 러스트는 무언가 잘못되었을 경우에 대한 처리를 위한 몇가지 기능을 갖추고 있습니다. 많은 경우, 러스트는 여러분이 에러가 발생할 가능성을 인정하고 여러분의 코드가 컴파일 되기 전에 어떤 행동을 취하기를 요구할 것입니다. 이러한 요구사항은 여러분의 코드를 제품으로서 배포하기 전에 에러를 발견하고 적절히 조치할 것이라고 보장함으로써 여러분의 프로그램을 더 강건하게 해줍니다!

러스트는 에러를 두 가지 범주로 묶습니다: 복구 가능한(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! 호출을 시도해 봅시다:

Filename: src/main.rs

fn main() {
    panic!("crash and burn");
}

이 프로그램을 실행하면, 다음과 같은 것을 보게 될 것입니다:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25 secs
     Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/panic` (exit code: 101)

panic!의 호출이 마지막 세 줄의 에러 메세지를 야기합니다. 첫번째 줄은 우리의 패닉 메세지와 소스 코드에서 패닉이 발생한 지점을 보여줍니다: src/main.rs:2src/main.rs 파일의 두번째 줄을 가리킵니다.

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

panic! 백트레이스 사용하기

다른 예를 통해서, 우리 코드가 직접 매크로를 호출하는 대신 우리 코드의 버그 때문에 panic! 호출이 라이브러리로부터 발생될 때는 어떻게 되는지 살펴봅시다. Listing 9-1은 벡터 내의 요소를 인덱스로 접근 시도하는 코드입니다:

Filename: src/main.rs

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

Listing 9-1: panic!을 일으키는 벡터의 끝을 넘어선 요소에 대한 접근 시도

여기서 우리는 벡터의 100번째 요소(0부터 시작하여 100번째)에 접근하기를 시도하고 있지만, 벡터는 오직 3개의 요소만 가지고 있습니다. 이러한 상황이면 러스트는 패닉을 일으킬 것입니다. []를 사용하는 것은 어떤 요소를 반환하기를 가정하지만, 유효하지 않은 인덱스를 넘기게 되면 러스트가 반환할 올바른 요소는 없습니다.

이러한 상황에서 C와 같은 다른 언어들은 여러분이 원하는 것이 아닐지라도, 여러분이 요청한 것을 정확히 주려고 시도할 것입니다: 여러분은 벡터 내에 해당 요소와 상응하는 위치의 메모리에 들어있는 무언가를 얻을 것입니다. 설령 그 메모리 영역이 벡터 소유가 아닐지라도 말이죠. 이러한 것을 버퍼 오버리드(buffer overread) 라고 부르며, 만일 어떤 공격자가 읽도록 허용되어선 안되지만 배열 뒤에 저장되어 있는 데이터를 읽어낼 방법으로서 인덱스를 다룰 수 있게 된다면, 이는 보안 취약점을 발생시킬 수 있습니다.

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

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27 secs
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is
100', /stable-dist-rustc/build/src/libcollections/vec.rs:1362
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/panic` (exit code: 101)

위 에러는 우리가 작성하지 않은 파일인 libcollections/vec.rs를 가리키고 있습니다. 이는 표준 라이브러리 내에 있는 Vec<T>의 구현 부분입니다. 우리가 벡터 v[]를 사용할 때 실행되는 코드는 libcollections/vec.rs 안에 있으며, 그곳이 바로 panic!이 실제 발생한 곳입니다.

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

$ RUST_BACKTRACE=1 cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 100', /stable-dist-rustc/build/src/libcollections/vec.rs:1392
stack backtrace:
   1:     0x560ed90ec04c - std::sys::imp::backtrace::tracing::imp::write::hf33ae72d0baa11ed
                        at /stable-dist-rustc/build/src/libstd/sys/unix/backtrace/tracing/gcc_s.rs:42
   2:     0x560ed90ee03e - std::panicking::default_hook::{{closure}}::h59672b733cc6a455
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:351
   3:     0x560ed90edc44 - std::panicking::default_hook::h1670459d2f3f8843
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:367
   4:     0x560ed90ee41b - std::panicking::rust_panic_with_hook::hcf0ddb069e7abcd7
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:555
   5:     0x560ed90ee2b4 - std::panicking::begin_panic::hd6eb68e27bdf6140
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:517
   6:     0x560ed90ee1d9 - std::panicking::begin_panic_fmt::abcd5965948b877f8
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:501
   7:     0x560ed90ee167 - rust_begin_unwind
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:477
   8:     0x560ed911401d - core::panicking::panic_fmt::hc0f6d7b2c300cdd9
                        at /stable-dist-rustc/build/src/libcore/panicking.rs:69
   9:     0x560ed9113fc8 - core::panicking::panic_bounds_check::h02a4af86d01b3e96
                        at /stable-dist-rustc/build/src/libcore/panicking.rs:56
  10:     0x560ed90e71c5 - <collections::vec::Vec<T> as core::ops::Index<usize>>::index::h98abcd4e2a74c41
                        at /stable-dist-rustc/build/src/libcollections/vec.rs:1392
  11:     0x560ed90e727a - panic::main::h5d6b77c20526bc35
                        at /home/you/projects/panic/src/main.rs:4
  12:     0x560ed90f5d6a - __rust_maybe_catch_panic
                        at /stable-dist-rustc/build/src/libpanic_unwind/lib.rs:98
  13:     0x560ed90ee926 - std::rt::lang_start::hd7c880a37a646e81
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:436
                        at /stable-dist-rustc/build/src/libstd/panic.rs:361
                        at /stable-dist-rustc/build/src/libstd/rt.rs:57
  14:     0x560ed90e7302 - main
  15:     0x7f0d53f16400 - __libc_start_main
  16:     0x560ed90e6659 - _start
  17:                0x0 - <unknown>

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

출력이 엄청 많군요! 여러분이 보는 실제 출력값은 여러분의 운영 체제 및 러스트 버전에 따라 다를 수 있습니다. 이러한 정보들과 함께 백트레이스를 얻기 위해서는 디버그 심볼이 활성화 되어 있어야 합니다. 디버그 심볼은 여기서와 마찬가지로 여러분이 cargo buildcargo run--release 플래그 없이 실행했을 때 기본적으로 활성화됩니다.

Listing 9-2의 출력값 내에서, 백트레이스의 11번 라인이 문제를 일으킨 우리 프로젝트의 라인을 가리키고 있습니다: 바로 src/main.rs, 4번 라인입니다. 만일 프로그램이 패닉에 빠지지 않도록 하고 싶다면, 우리가 작성한 파일이 언급된 첫 라인으로 지적된 위치가 바로 패닉을 일으킨 값을 가지고 있는 위치를 찾아내기 위해 수사하기 시작할 지점입니다. 백트레이스를 어떻게 사용하는지 시범을 보이기 위해 고의로 패닉을 일으키는 코드를 작성한 우리의 예제에서, 패닉을 고칠 방법은 고작 3개의 아이템을 가진 벡터로부터 인덱스 100에서의 요소를 요청하지 않도록 하는 것입니다. 여러분의 코드가 추후 패닉에 빠졌을 때, 여러분의 특정한 경우에 대하여 어떤 코드가 패닉을 일으키는 값을 만드는지와 코드는 대신 어떻게 되어야 할지를 알아낼 필요가 있을 것입니다.

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

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

대부분의 에러는 프로그램을 전부 멈추도록 요구될 정도로 심각하지는 않습니다. 종종 어떤 함수가 실패할 때는, 우리가 쉽게 해석하고 대응할 수 있는 이유에 대한 것입니다. 예를 들어, 만일 우리가 어떤 파일을 여는데 해당 파일이 존재하지 않아서 연산에 실패했다면, 프로세스를 멈추는 대신 파일을 새로 만드는 것을 원할지도 모릅니다.

2장의 “Result 타입으로 잠재된 실패 다루기” 절에서 Result 열거형은 다음과 같이 OkErr라는 두 개의 variant를 갖도록 정의되어 있음을 상기하세요:


# #![allow(unused_variables)]
#fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
#}

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

실패할 수도 있기 때문에 Result 값을 반환하는 함수를 호출해 봅시다: Listing 9-3에서는 파일 열기를 시도합니다:

Filename: src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");
}

Listing 9-3: 파일 열기

File::openResult를 반환하는지 어떻게 알까요? 표준 라이브러리 API 문서를 찾아보거나, 컴파일러에게 물어볼 수 있습니다! 만일 f에게 우리가 알고 있고 그 함수의 반환 타입은 아닐 어떤 타입에 대한 타입 명시를 주고 그 코드의 컴파일을 시도한다면, 컴파일러는 우리에게 타입이 맞지 않는다고 알려줄 것입니다. 그후 에러 메세지는 f의 타입이 무엇인지 알려줄 것입니다. 한번 해봅시다: 우리는 File::open의 반환 타입이 u32는 아니라는 것을 알고 있으니, let f 구문을 이렇게 바꿔봅시다:

let f: u32 = File::open("hello.txt");

이제 컴파일을 시도하면 다음 메세지가 나타납니다:

error[E0308]: mismatched types
 --> src/main.rs:4:18
  |
4 |     let f: u32 = File::open("hello.txt");
  |                  ^^^^^^^^^^^^^^^^^^^^^^^ expected u32, found enum
`std::result::Result`
  |
  = note: expected type `u32`
  = note:    found type `std::result::Result<std::fs::File, std::io::Error>`

이 메세지는 File::open 함수의 반환 타입이 Result<T, E>라는 것을 알려줍니다. 여기서 제네릭 파라미터 T는 성공값의 타입인 std::fs::File로 체워져 있는데, 이것은 파일 핸들입니다. 에러에 사용되는 E의 타입은 std::io::Error입니다.

이 반환 타입은 File::open을 호출하는 것이 성공하여 우리가 읽거나 쓸 수 있는 파일 핸들을 반환해 줄 수도 있다는 뜻입니다. 함수 호출은 또한 실패할 수도 있습니다: 예를 들면 파일이 존재하지 않거나 파일에 접근할 권한이 없을지도 모릅니다. File::open 함수는 우리에게 성공했는지 혹은 실패했는지를 알려주면서 동시에 파일 핸들이나 에러 정보 둘 중 하나를 우리에게 제공할 방법을 가질 필요가 있습니다. 바로 이러한 정보가 Result 열거형이 전달하는 것과 정확히 일치합니다.

File::open이 성공한 경우, 변수 f가 가지게 될 값은 파일 핸들을 담고 있는 Ok 인스턴스가 될 것입니다. 실패한 경우, f의 값은 발생한 에러의 종류에 대한 더 많은 정보를 가지고 있는 Err의 인스턴스가 될 것입니다.

우리는 Listing 9-3의 코드에 File::open이 반환하는 값에 따라 다른 행동을 취하는 코드를 추가할 필요가 있습니다. Listing 9-4은 우리가 6장에서 다뤘던 기초 도구 match 표현식을 이용하여 Result를 처리하는 한 가지 방법을 보여줍니다:

Filename: src/main.rs

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의 각 경우에 대해서 OkErr 앞에 Result::를 특정하지 않아도 됩니다.

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

match의 다른 경우는 File::open으로부터 Err를 얻은 경우를 처리합니다. 이 예제에서는 panic! 매크로를 호출하는 방법을 택했습니다. 우리의 현재 디렉토리 내에 hello.txt라는 이름의 파일이 없는데 이 코드를 실행하게 되면, panic! 매크로로부터 다음과 같은 출력을 보게 될 것입니다:

thread 'main' panicked at 'There was a problem opening the file: Error { repr:
Os { code: 2, message: "No such file or directory" } }', src/main.rs:9:12

늘 그렇듯이, 이 출력은 어떤 것이 잘못되었는지 정확히 알려줍니다.

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

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

Filename: src/main.rs

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
            )
        },
    };
}

Listing 9-5: 다른 종류의 에러를 다른 방식으로 처리하기

Err variant 내에 있는 File::open이 반환하는 값의 타입은 io::Error인데, 이는 표준 라이브러리에서 제공하는 구조체입니다. 이 구조체는 kind 메소드를 제공하는데 이를 호출하여 io::ErrorKind값을 얻을 수 있습니다. io::ErrorKindio 연산으로부터 발생할 수 있는 여러 종류의 에러를 표현하는 variant를 가진, 표준 라이브러리에서 제공하는 열거형입니다. 우리가 사용하고자 하는 variant는 ErrorKind::NotFound인데, 이는 열고자 하는 파일이 아직 존재하지 않음을 나타냅니다.

조건문 if error.kind() == ErrorKind::NotFound매치 가드(match guard) 라고 부릅니다: 이는 match 줄기 상에서 줄기의 패턴을 좀더 정제해주는 추가 조건문입니다. 그 줄기의 코드가 실행되기 위해서는 이 조건문이 참이어야 합니다; 그렇지 않다면, 패턴 매칭은 match의 다음 줄기에 맞춰보기 위해 이동할 것입니다. 패턴에는 ref가 필요하며 그럼으로써 error가 가드 조건문으로 소유권 이동이 되지 않고 그저 참조만 됩니다. 패턴 내에서 참조자를 얻기 위해 &대신 ref이 사용되는 이유는 18장에서 자세히 다룰 것입니다. 짧게 설명하면, &는 참조자를 매치하고 그 값을 제공하지만, ref는 값을 매치하여 그 참조자를 제공합니다.

매치 가드 내에서 확인하고자 하는 조건문은 error.kind()에 의해 반환된 값이 ErrorKind 열거형의 NotFound variant인가 하는 것입니다. 만일 그렇다면, File::create로 파일 생성을 시도합니다. 그러나, File::create 또한 실패할 수 있기 때문에, 안쪽에 match 구문을 바깥쪽과 마찬가지로 추가할 필요가 있습니다. 파일이 열수 없을 때, 다른 에러 메세지가 출력될 것입니다. 바깥쪽 match의 마지막 갈래는 똑같이 남아서, 파일을 못 찾는 에러 외에 다른 어떤 에러에 대해서도 패닉을 일으킵니다.

에러가 났을 때 패닉을 위한 숏컷: unwrapexpect

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

Filename: src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").unwrap();
}

hello.txt 파일이 없는 상태에서 이 코드를 실행시키면, unwrap 메소드에 의한 panic! 호출로부터의 에러 메세지를 보게 될 것입니다:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error {
repr: Os { code: 2, message: "No such file or directory" } }',
/stable-dist-rustc/build/src/libcore/result.rs:868

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

Filename: src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").expect("Failed to open hello.txt");
}

expectunwrap과 같은 식으로 사용됩니다: 파일 핸들을 리턴하거나 panic! 매크로를 호출하는 것이죠. expectpanic! 호출에 사용하는 에러 메세지는 unwrap이 사용하는 기본 panic! 메세지보다는 expect에 넘기는 파라미터로 설정될 것입니다. 아래에 어떻게 생겼는지에 대한 예가 있습니다:

thread 'main' panicked at 'Failed to open hello.txt: Error { repr: Os { code:
2, message: "No such file or directory" } }',
/stable-dist-rustc/build/src/libcore/result.rs:868

이 에러 메세지는 우리가 특정한 텍스트인 Failed to open hello.txt로 시작하기 때문에, 이 에러 메세지가 어디서부터 왔는지를 코드 내에서 찾기가 더 수월해질 것입니다. 만일 우리가 여러 군데에 unwrap을 사용하면, 정확히 어떤 unwrap이 패닉을 일으켰는지 찾기에 좀 더 많은 시간이 걸릴 수 있는데, 그 이유는 패닉을 호출하는 모든 unwrap이 동일한 메세지를 출력하기 때문입니다.

에러 전파하기

실패할지도 모르는 무언가를 호출하는 구현을 가진 함수를 작성할때, 이 함수 내에서 에러를 처리하는 대신, 에러를 호출하는 코드쪽으로 반환하여 그쪽에서 어떻게 할지 결정하도록 할 수 있습니다. 이는 에러 전파하기로 알려져 있으며, 에러가 어떻게 처리해야 좋을지 좌우해야 할 상황에서, 여러분의 코드 내용 내에서 이용 가능한 것들보다 더 많은 정보와 로직을 가지고 있을 수도 있는 호출하는 코드쪽에 더 많은 제어권을 줍니다.

예를 들면, Listing 9-6는 파일로부터 사용자 이름을 읽는 함수를 작성한 것입니다. 만일 파일이 존재하지 않거나 읽을 수 없다면, 이 함수는 호출하는 코드쪽으로 해당 에러를 반환할 것입니다:

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
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 함수를 호출하면서 시작합니다. 그 다음에는 Listing 9-4에서 본 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의 구현을 보여주는데, 다만 이 구현은 물음표 연산자를 이용하고 있습니다:

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
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)
}
#}

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

Result 값 뒤의 ?는 Listing 9-6에서 Result 값을 다루기 위해 정의했던 match 표현식과 거의 같은 방식으로 동작하게끔 정의되어 있습니다. 만일 Result의 값이 Ok라면, Ok 내의 값이 이 표현식으로부터 얻어지고 프로그램이 계속됩니다. 만일 값이 Err라면, 우리가 return 키워드를 사용하여 에러 값을 호출하는 코드에게 전파하는 것과 같이 전체 함수로부터 Err 내의 값이 반환될 것입니다.

Listing 9-6에 있는 match 표현식과 물음표 연산자가 수행하는 한가지 차이점은 물음표 연산자를 사용할 때 에러값들이 표준 라이브러리 내에 있는 From 트레잇에 정의된 from 함수를 친다는 것입니다. 많은 에러 타입들이 어떤 타입의 에러를 다음 타입의 에러로 변환하기 위해 from 함수를 구현하였습니다. 물음표 연산자가 사용되면, from 함수의 호출이 물음표 연산자가 얻게 되는 에러 타입을 ?이 사용되고 있는 현재 함수의 반환 타입에 정의된 에러 타입으로 변환합니다. 이는 어떤 함수의 부분들이 수많은 다른 이유로 인해 실패할 수 있지만 이 함수는 실패하는 모든 방식을 하나의 에러 타입으로 반환할 때 유용합니다. 각각의 에러 타입이 그 자신을 반환되는 에러 타입으로 변경할 방법을 정의하기 위해 from 함수를 구현하기만 한다면, 물음표 연산자는 이 변환을 자동적으로 다룹니다.

Listing 9-7의 내용에서, File::open 호출 부분의 끝에 있는 ?Ok내의 값을 변수 f에게 반환해줄 것입니다. 만일 에러가 발생하면 ?는 전체 함수로부터 일찍 빠져나와 호출하는 코드에게 어떤 Err 값을 줄 것입니다. read_to_string 호출의 끝부분에 있는 ?도 같은 것이 적용되어 있습니다.

?는 많은 수의 보일러플레이트(boilerplate)를 제거해주고 이 함수의 구현을 더 단순하게 만들어 줍니다. 심지어는 Listing 9-8과 같이 ? 뒤에 바로 메소드 호출을 연결하는 식으로 (chaining) 이 코드를 더 줄일 수도 있습니다:

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut s = String::new();

    File::open("hello.txt")?.read_to_string(&mut s)?;

    Ok(s)
}
#}

Listing 9-8: 물음표 연산자 뒤에 메소드 호출을 연결하기

새로운 String을 만들어 s에 넣는 부분을 함수의 시작 부분으로 옮겼습니다; 이 부분은 달라진 것이 없습니다. f 변수를 만드는 대신, File::open("hello.txt")?의 결과 바로 뒤에 read_to_string의 호출을 연결시켰습니다. read_to_string 호출의 끝에는 여전히 ?가 남아있고, File::openread_to_string이 모두 에러를 반환하지 않고 성공할 때 s 안의 사용자 이름을 담은 Ok를 여전히 반환합니다. 함수의 기능 또한 Lsting 9-6와 Listing 9-7의 것과 동일하고, 다만 작성하기에 더 인체공학적인 방법이라는 차이만 있을 뿐입니다.

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

?Result 타입을 반환하는 함수에서만 사용이 가능한데, 이것이 Listing 9-6에 정의된 match 표현식과 동일한 방식으로 동작하도록 정의되어 있기 때문입니다. Result 반환 타입을 요구하는 match 부분은 return Err(e)이며, 따라서 함수의 반환 타입은 반드시 이 return과 호환 가능한 Result가 되어야 합니다.

main의 반환 타입이 ()라는 것을 상기하면서, 만약 main 함수 내에서 ?를 사용하면 어떤일이 생길지 살펴봅시다:

use std::fs::File;

fn main() {
    let f = File::open("hello.txt")?;
}

이걸 컴파일하면, 아래와 같은 에러 메세지가 뜹니다:

error[E0277]: the `?` operator can only be used in a function that returns
`Result` (or another type that implements `std::ops::Try`)
 --> src/main.rs:4:13
  |
4 |     let f = File::open("hello.txt")?;
  |             ------------------------
  |             |
  |             cannot use the `?` operator in a function that returns `()`
  |             in this macro invocation
  = help: the trait `std::ops::Try` is not implemented for `()`
  = note: required by `std::ops::Try::from_error`

이 에러는 오직 Result를 반환하는 함수 내에서만 물음표 연산자를 사용할 수 있음을 지적합니다. Result를 반환하지 않는 함수 내에서, 여러분이 Result를 반환하는 다른 함수를 호출했을 때, 여러분은 ?를 사용하여 호출하는 코드에게 잠재적으로 에러를 전파하는 대신 matchResult에서 제공하는 메소드들 중 하나를 사용하여 이를 처리할 필요가 있을 것입니다.

panic!을 호출하거나 Result를 반환하는 것의 자세한 부분을 논의했으니, 어떤 경우에 어떤 방법을 사용하는 것이 적합할지를 어떻게 결정하는가에 대한 주제로 돌아갑시다.

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

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

몇가지 상황에서는 Result를 반환하는 대신 패닉을 일으키는 코드를 작성하는 것이 더 적합하지만, 덜 일반적입니다. 예제, 프로토타입 코드 및 테스트의 경우에는 왜 패닉이 더 좋은지를 탐구합시다; 그 다음, 사람으로서의 여러분이라면 실패할 리 없는 메소드라는 것을 알수 있지만 컴파일러는 이유를 파악할 수 없는 경우도 봅시다; 그리고 라이브러리 코드에 패닉을 추가해야 할지 말지를 어떻게 결정할까에 대한 일반적인 가이드라인을 내림으로서 결론지어 봅시다.

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

여러분이 어떤 개념을 그려내기 위한 예제를 작성중이라면, 강건한 에러 처리 코드를 예제 안에 넣는 것은 또한 예제를 덜 깨끗하게 만들 수 있습니다. 예제 코드 내에서는 panic!을 일으킬 수 있는 unwrap 같은 메소드를 호출하는 것이 여러분의 어플리케이션이 에러를 처리하고자 하는 방법에 대한 플레이스홀더로서의 의미를 갖는데, 이는 여러분의 코드의 나머지 부분이 어떤 것을 하는지에 따라 달라질 수 있습니다.

비슷한 상황에서, 여러분이 에러를 어떻게 처리할지 결정할 준비가 되기 전에는, unwrapexpect 메소드가 프로토타이핑을 할때 매우 편리합니다. 이 함수들은 여러분의 코드를 더 강건하게 만들 준비가 되었을 때를 위해서 명확한 표시를 남겨 둡니다.

만일 테스트 내에서 메소드 호출이 실패한다면, 해당 메소드가 테스트 중인 기능이 아니더라도 전체 테스트를 실패시키도록 합니다. panic!은 테스트가 어떻게 실패하는지 표시해주기 때문에, unwrap이나 expect를 호출하는 것은 정확하게 하고자 하는 일과 일치합니다.

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

ResultOk 값을 가지고 있을 거라 확신할 다른 논리를 가지고 있지만, 그 논리가 컴파일러에 의해 이해할 수 있는 것이 아닐 때라면, unwrap을 호출하는 것이 또한 적절할 수 있습니다. 여러분은 여전히 처리할 필요가 있는 Result 값을 가지고 있습니다: 여러분의 특정한 상황에서 논리적으로 불가능할지라도, 여러분이 호출하고 있는 연산이 무엇이든간에 일반적으로는 여전히 실패할 가능성이 있습니다. 만일 여러분이 수동적으로 Err variant를 결코 발생시키지 않는 코드를 조사하여 확신할 수 있다면, unwrap을 호출하는 것이 완벽히 허용됩니다. 여기 예제가 있습니다:


# #![allow(unused_variables)]
#fn main() {
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이 아닌 어떤 타입을 갖고 있다면, 여러분의 프로그램은 아무것도 아닌 것이 아닌 무언가를 갖고 있음을 예측합니다. 그러면 여러분의 코드는 SomeNone variant에 대한 두 경우를 처리하지 않아도 됩니다: 이는 분명히 값을 가지고 있는 하나의 경우만 있을 것입니다. 여러분의 함수에 아무것도 넘기지 않는 시도를 하는 코드는 컴파일 조차 되지 않을 것이고, 따라서 여러분의 함수는 그러한 경우에 대해서 런타임에 검사하지 않아도 됩니다. 또다른 예로는 u32와 같은 부호없는 정수를 이용하는 것이 있는데, 이는 파라미터가 절대 음수가 아님을 보장합니다.

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

러스트의 타입 시스템을 이용하여 유효한 값을 보장하는 아이디어에서 한발 더 나가서, 유효성을 위한 커스텀 타입을 생성하는 것을 살펴봅시다. 2장의 추리 게임을 상기해 보시면, 우리의 코드는 사용자에게 1부터 100사이의 숫자를 추측하도록 요청했었죠. 우리는 실제로는 사용자의 추측값이 우리의 비밀 숫자와 비교하기 전에 해당 값이 유효한지 결코 확인하지 않았습니다; 우리는 추측값이 양수인지 만을 확인했습니다. 이 경우, 결과는 매우 끔찍하지는 않았습니다: “Too high”나 “Too low”라고 표시했던 출력은 여전히 맞을 것입니다. 사용자에게 유효한 추측값을 안내해주고, 사용자가 예를 들어 글자를 입력했을 때에 비해 사용자가 범위 밖의 값을 추측했을 때 다른 동작을 하는 것은 쓸모있는 향상일 것입니다.

이를 위한 한가지 방법은 u32 대신 i32로서 추측값을 파싱하여 음수가 입력될 가능성을 허용하고, 그리고나서 아래와 같이 숫자가 범위 내에 있는지에 대한 검사를 추가하는 것입니다:

loop {
    // snip

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

    if guess < 1 || guess > 100 {
        println!("The secret number will be between 1 and 100.");
        continue;
    }

    match guess.cmp(&secret_number) {
    // snip
}

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

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

대신, 우리는 새로운 타입을 만들어서, 유효성 확인을 모든 곳에서 반복하는 것보다는 차라리 그 타입의 인스턴스를 생성하는 함수 내에 유효성 확인을 넣을 수 있습니다. 이 방식에서, 함수가 그 시그니처 내에서 새로운 타입을 이용하고 받은 값을 자신있게 사용하는 것은 안전합니다. Listing 9-9은 new 함수가 1과 100 사이의 값을 받았을 때에만 인스턴스를 생성하는 Guess 타입을 정의하는 한가지 방법을 보여줍니다:


# #![allow(unused_variables)]
#fn main() {
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 문서 내에 다뤄져야 합니다; 여러분이 만드는 API 문서 내에서 panic!의 가능성을 가리키는 것에 대한 문서 관례는 14장에서 다룰 것입니다. 만일 value가 테스트를 통과한다면, value 항목을 value 파라미터로 설정한 새로운 Guess를 생성하여 이 Guess를 반환합니다.

다음으로, self를 빌리고, 파라미터를 갖지 않으며, u32를 반환하는 value라는 이름의 메소드를 구현했습니다. 이러한 종류 메소드를 종종 게터(getter) 라고 부르는데, 그 이유는 이런 함수의 목적이 객체의 항목으로부터 어떤 데이터를 가져와서 이를 반환하는 것이기 때문입니다. 이 공개 메소드는 Guess 구조체의 value 항목이 비공개이기 때문에 필요합니다. value 항목이 비공개라서 Guess 구조체를 이용하는 코드가 value를 직접 설정하지 못하도록 하는 것은 중요합니다: 모듈 밖의 코드는 반드시 Guess::new 함수를 이용하여 새로운 Guess의 인스턴스를 만들어야 하는데, 이는 GuessGuess::new 함수의 조건들을 확인한 적이 없는 value를 갖는 방법이 없음을 보장합니다.

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

정리

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

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

제네릭 타입, 트레잇, 그리고 라이프타임

모든 프로그래밍 언어는 컨셉의 복제를 효율적으로 다루기 위한 도구를 가지고 있습니다; 러스트에서, 그러한 도구 중 하나가 바로 제네릭(generic) 입니다. 제네릭은 구체화된 타입이나 다른 속성들에 대하여 추상화된 대리인입니다. 코드를 작성하고 컴파일할 때, 우리는 제네릭들이 실제로 어떻게 완성되는지 알 필요 없이, 제네릭의 동작 혹은 다른 제네릭과 어떻게 연관되는지와 같은 제네릭에 대한 속성을 표현할 수 있습니다.

여러 개의 구체화된 값들에 대해 실행될 코드를 작성하기 위해서 함수가 어떤 값을 담을지 알 수 없는 파라미터를 갖는 것과 동일한 방식으로, i32String과 같은 구체화된 타입 대신 몇몇 제네릭 타입의 파라미터를 갖는 함수를 작성할 수 있습니다. 우리는 6장의 Option<T>, 8장의 Vec<T>HashMap<K, V>, 그리고 9장의 Result<T, E>에서 이미 제네릭을 사용해 보았습니다. 이 장에서는, 어떤 식으로 우리만의 타입, 함수, 그리고 메소드를 제네릭으로 정의하는지 탐험해 볼 것입니다!

우선, 우리는 코드 중복을 제거하는 함수의 추출하는 원리에 대해 돌아볼 것입니다. 그러고 나서 두 함수가 오직 파라미터의 타입만 다른 경우에 대하여 이들을 하나의 제네릭 함수로 만들기 위해 동일한 원리를 사용할 것입니다. 또한 제네릭 타입을 구조체와 열거형의 정의에 사용하는 것을 살펴볼 것입니다.

그리고 난 후 트레잇(trait) 에 대하여 논의할 것인데, 이는 동작을 제네릭 한 방식으로 정의하는 방법을 말합니다. 트레잇은 제네릭 타입과 결합되어 제네릭 타입에 대해 아무 타입이나 허용하지 않고, 특정 동작을 하는 타입으로 제한할 수 있습니다.

마지막으로, 우리는 라이프타임(lifetime) 에 대해 다룰 것인데, 이는 제네릭의 일종으로서 우리가 컴파일러에게 참조자들이 서로에게 어떤 연관이 있는지에 대한 정보를 줄 수 있도록 해줍니다. 라이프타임은 수많은 상황에서 값을 빌릴 수 있도록 허용해 주고도 여전히 참조자들이 유효할지를 컴파일러가 검증하도록 해주는 러스트의 지능입니다.

함수를 추출하여 중복 없애기

제네릭 문법을 들어가기 전에, 먼저 제네릭 타입을 이용하지 않는 중복 코드 다루기 기술을 훑어봅시다: 바로 함수 추출하기죠. 이를 한번 우리 마음속에서 생생하게 상기시키고 나면, 우리는 제네릭 함수를 추출하기 위해 제네릭을 가지고 똑같은 수법을 이용할 것입니다! 여러분이 함수로 추출할 중복된 코드를 인식하는 것과 똑같은 방식으로, 여러분은 제네릭을 이용할 수 있는 중복된 코드를 인식하기 시작할 것입니다.

Listing 10-1과 같이 리스트에서 가장 큰 숫자를 찾아내는 작은 프로그램이 있다고 칩시다:

Filename: src/main.rs

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let mut largest = numbers[0];

    for number in numbers {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {}", largest);
#  assert_eq!(largest, 100);
}

Listing 10-1: 숫자 리스트 중에서 가장 큰 수를 찾는 코드

이 코드는 정수의 리스트를 얻는데, 여기서는 변수 numbers에 저장되어 있습니다. 리스트의 첫 번째 아이템을 largest라는 이름의 변수에 우선 집어넣습니다. 그러고 나서 리스트 내의 모든 숫자들에 대해 반복 접근을 하는데, 만일 현재 숫자가 largest 내에 저장된 숫자보다 더 크다면, 이 숫자로 largest 내의 값을 변경합니다. 만일 현재 숫자가 여태까지 본 가장 큰 값보다 작다면, largest는 바뀌지 않습니다. 리스트 내의 모든 아이템을 다 처리했을 때, largest는 가장 큰 값을 가지고 있을 것인데, 위 코드의 경우에는 100이 될 것입니다.

만일 두 개의 서로 다른 숫자 리스트로부터 가장 큰 숫자를 찾기를 원한다면, Listing 10-1의 코드를 복사하여, Listing 10-2에서처럼 한 프로그램 내에 동일한 로직이 두 군데 있게 할 수도 있습니다:

Filename: src/main.rs

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let mut largest = numbers[0];

    for number in numbers {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {}", largest);

    let numbers = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = numbers[0];

    for number in numbers {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {}", largest);
}

Listing 10-2: 개의 숫자 리스트에서 가장 큰 숫자를 찾는 코드

이 코드는 잘 동작하지만, 코드를 중복 적용하는 일은 지루하고 오류가 발생하기도 쉬우며, 또한 로직을 바꾸고 싶다면 이 로직을 갱신할 곳이 여러 군데가 된다는 의미이기도 합니다.

이러한 중복을 제거하기 위해서 우리는 추상화를 쓸 수 있는데, 이 경우에는 어떠한 정수 리스트가 함수의 파라미터로 주어졌을 때 동작하는 함수의 형태가 될 것입니다. 이는 우리 코드의 명료성을 증가시켜주고 리스트 내에서 가장 큰 수를 찾는 컨셉을 사용하는 특정한 위치와 상관없이 이러한 컨셉을 전달하고 추론하도록 해줍니다.

Listing 10-3의 프로그램에서는 가장 큰 수를 찾는 코드를 largest라는 이름의 함수로 추출했습니다. 이 프로그램은 두 개의 서로 다른 숫자 리스트에서 가장 큰 수를 찾을 수 있지만, Listing 10-1에서의 코드는 한 군데에서만 나타납니다:

Filename: src/main.rs

fn largest(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let result = largest(&numbers);
    println!("The largest number is {}", result);
#    assert_eq!(result, 100);

    let numbers = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&numbers);
    println!("The largest number is {}", result);
#    assert_eq!(result, 6000);
}

Listing 10-3: 두 리스트에서 가장 큰 수를 찾는 추상화된 코드

이 함수는 list라는 파라미터를 갖고 있는데, 이것이 함수로 넘겨질 구체적인 임의 i32 값들의 슬라이스를 나타냅니다. 함수 정의 내의 코드는 임의의 &[i32]list 표현에 대해 동작합니다. largest 함수를 호출할 때, 이 코드는 실제로 우리가 넘겨준 구체적인 값에 대해 실행됩니다.

Listing 10-2에서부터 Listing 10-3까지 우리가 살펴본 원리는 아래와 같은 단계로 진행되었습니다:

  1. 중복된 코드가 있음을 알아챘습니다.
  2. 중복된 코드를 함수의 본체로 추출하고, 함수의 시그니처 내에 해당 코드의 입력값 및 반환 값을 명시했습니다.
  3. 두 군데의 코드가 중복되었던 구체적인 지점에 함수 호출을 대신 집어넣었습니다.

우리는 다른 시나리오 상에서 다른 방식으로 제네릭을 가지고 중복된 코드를 제거하기 위해 같은 단계를 밟을 수 있습니다. 함수의 본체가 현재 구체적인 값 대신 추상화된 list에 대해 동작하고 있는 것과 같이, 제네릭을 이용한 코드는 추상화된 타입에 대해 작동할 것입니다. 제네릭으로 강화되는 컨셉은 여러분이 이미 알고 있는 함수로 강화되는 컨셉과 동일하며, 다만 다른 방식으로 적용될 뿐입니다.

만일 우리가 두 개의 함수를 가지고 있는데, 하나는 i32의 슬라이스에서 최댓값을 찾는 것이고 다른 하나는 char 값의 슬라이스에서 최댓값을 찾는 것이라면 어떨까요? 어떻게 하면 이런 중복을 제거할 수 있을까요? 한번 알아봅시다!

제네릭 데이터 타입

함수 시그니처나 구조체에서와 같은 방식으로, 우리가 일반적으로 타입을 쓰는 곳에다 제네릭을 이용하는 것은 여러 다른 종류의 구체적인 데이터 타입에 대해 사용할 수 있는 정의를 생성하도록 해줍니다. 제네릭을 이용하여 함수, 구조체, 열거형, 그리고 메소드를 정의하는 방법을 살펴본 뒤, 이 절의 끝에서 제네릭을 이용한 코드의 성능에 대해 논의하겠습니다.

함수 정의 내에서 제네릭 데이터 타입을 이용하기

우리는 함수의 시그니처 내에서 파라미터의 데이터 타입과 반환 값이 올 자리에 제네릭을 사용하는 함수를 정의할 수 있습니다. 이러한 방식으로 작성된 코드는 더 유연해지고 우리 함수를 호출하는 쪽에서 더 많은 기능을 제공할 수 있는 한편, 코드 중복을 야기하지도 않습니다.

우리의 largest 함수로 계속 진행하면, Listing 10-4는 슬라이스 내에서 가장 큰 값을 찾는 동일한 기능을 제공하는 두 함수를 보여주고 있습니다. 첫 번째 함수는 Listing 10-3에서 추출한 슬라이스에서 가장 큰 i32를 찾는 함수입니다. 두 번째 함수는 슬라이스에서 가장 큰 char를 찾습니다:

Filename: src/main.rs

fn largest_i32(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> char {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&numbers);
    println!("The largest number is {}", result);
#    assert_eq!(result, 100);

    let chars = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&chars);
    println!("The largest char is {}", result);
#    assert_eq!(result, 'y');
}

Listing 10-4: 이름과 시그니처만 다른 두 함수들

여기서 함수 largest_i32largest_char는 정확히 똑같은 본체를 가지고 있으므로, 만일 우리가 이 두 함수를 하나로 바꿔서 중복을 제거할 수 있다면 좋을 것입니다. 운 좋게도, 제네릭 타입 파라미터를 도입해서 그렇게 할 수 있습니다!

우리가 정의하고자 하는 함수의 시그니처 내에 있는 타입들을 파라미터화 하기 위해서, 타입 파라미터를 위한 이름을 만들 필요가 있는데, 이는 값 파라미터들의 이름을 함수에 제공하는 방법과 유사합니다. 우리는 T라는 이름을 선택할 겁니다. 어떤 식별자(identifier)든지 타입 파라미터의 이름으로 사용될 수 있지만, 러스트의 타입 이름에 대한 관례가 낙타 표기법(CamelCase)이기 때문에 T를 사용하려고 합니다. 제네릭 타입 파라미터의 이름은 또한 관례상 짧은 경향이 있는데, 종종 그냥 한 글자로 되어 있습니다. "type"을 줄인 것으로서, T가 대부분의 러스트 프로그래머의 기본 선택입니다.

함수의 본체에 파라미터를 이용할 때는, 시그니처 내에 그 파라미터를 선언하여 해당 이름이 함수 본체 내에서 무엇을 의미하는지 컴파일러가 할 수 있도록 합니다. 비슷하게, 함수 시그니처 내에서 타입 파라미터 이름을 사용할 때는, 사용 전에 그 타입 파라미터 이름을 선언해야 합니다. 타입 이름 선언은 함수의 이름과 파라미터 리스트 사이에 꺾쇠괄호를 쓰고 그 안에 넣습니다.

우리가 정의하고자 하는 제네릭 largest 함수의 함수 시그니처는 아래와 같이 생겼습니다:

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

이를 다음과 같이 읽습니다: 함수 largest는 어떤 타입 T을 이용한 제네릭입니다. 이것은 list라는 이름을 가진 하나의 파라미터를 가지고 있고, list의 타입은 T 타입 값들의 슬라이스입니다. largest 함수는 동일한 타입 T 값을 반환할 것입니다.

Listing 10-5는 함수 시그니처 내에 제네릭 데이터 타입을 이용한 통합된 형태의 largest 함수 정의를 보여주며, 또한 i32 값들의 슬라이스 혹은 char 값들의 슬라이스를 가지고 어떻게 largest를 호출할 수 있을지를 보여줍니다. 이 코드가 아직 컴파일되지 않는다는 점을 주의하세요!

Filename: src/main.rs

fn largest<T>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let result = largest(&numbers);
    println!("The largest number is {}", result);

    let chars = vec!['y', 'm', 'a', 'q'];

    let result = largest(&chars);
    println!("The largest char is {}", result);
}

Listing 10-5: 제네릭 타입 파라미터를 이용하지만 아직 컴파일되지 않는 largest 함수의 정의

이 코드를 지금 컴파일하고자 시도하면, 다음과 같은 에러를 얻게 될 것입니다:

error[E0369]: binary operation `>` cannot be applied to type `T`
  |
5 |         if item > largest {
  |            ^^^^
  |
note: an implementation of `std::cmp::PartialOrd` might be missing for `T`

위 노트는 std::cmp::PartialOrd를 언급하는데, 이는 트레잇(trait) 입니다. 트레잇에 대해서는 다음 절에서 살펴볼 것이지만, 간략하게 설명하자면, 이 에러가 말하고 있는 것은 T가 될 수 있는 모든 가능한 타입에 대해서 동작하지 않으리라는 것입니다: 함수 본체 내에서 T 타입의 값을 비교하고자 하기 때문에, 어떻게 순서대로 정렬하는지 알고 있는 타입만 사용할 수 있는 것입니다. 표준 라이브러리는 어떤 타입에 대해 비교 연산이 가능하도록 구현할 수 있는 트레잇인 std::cmp::PartialOrd을 정의해뒀습니다. 다음 절에서 트레잇, 그리고 어떤 제네릭 타입이 특정 트레잇을 갖도록 명시하는 방법을 알아보기 위해 돌아올 것이지만, 이 예제는 잠시 옆으로 치워두고 제네릭 타입 파라미터를 이용할 수 있는 다른 곳을 먼저 돌아봅시다.

구조체 정의 내에서 제네릭 데이터 타입 사용하기

우리는 또한 하나 혹은 그 이상의 구조체 필드 내에 제네릭 타입 파라미터를 사용하여 구조체를 정의할 수 있습니다. Listing 10-6은 임의의 타입으로 된 xy 좌표값을 가질 수 있는 Point 구조체의 정의 및 사용법을 보여주고 있습니다:

Filename: src/main.rs

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

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

Listing 10-6: T 타입의 값 xy를 갖는 Point 구조체

문법은 함수 정의 내에서의 제네릭을 사용하는 것과 유사합니다. 먼저, 구조체 이름 바로 뒤에 꺾쇠괄호를 쓰고 그 안에 타입 파라미터의 이름을 선언해야 합니다. 그러면 구조체 정의부 내에서 구체적인 데이터 타입을 명시하는 곳에 제네릭 타입을 이용할 수 있습니다.

Point의 정의 내에서 단 하나의 제네릭 타입을 사용했기 때문에, Point 구조체는 어떤 타입 T를 이용한 제네릭이고 xy가 이게 결국 무엇이 되든 간에 둘 다 동일한 타입을 가지고 있다고 말할 수 있음을 주목하세요. 만일 Listing 10-7에서와 같이 다른 타입의 값을 갖는 Point의 인스턴스를 만들고자 한다면, 컴파일이 되지 않을 것입니다: `

Filename: src/main.rs

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

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

Listing 10-7: xy 필드는 둘 모두 동일한 제네릭 데이터 타입 T를 가지고 있기 때문에 동일한 타입이어야 합니다

이 코드를 컴파일하고자 하면, 다음과 같은 에러를 얻게 될 것입니다:

error[E0308]: mismatched types
 -->
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integral variable, found
  floating-point variable
  |
  = note: expected type `{integer}`
  = note:    found type `{float}`

x에 정수 5를 대입할 때, 컴파일러는 이 Point의 인스턴스에 대해 제네릭 타입 T가 정수일 것이고 알게 됩니다. 그다음 y에 대해 4.0을 지정했는데, 이 yx와 동일한 타입을 갖도록 정의되었으므로, 타입 불일치 에러를 얻게 됩니다.

만일 xy가 서로 다른 타입을 가지지만 해당 타입들이 여전히 제네릭인 Point 구조체를 정의하길 원한다면, 여러 개의 제네릭 타입 파라미터를 이용할 수 있습니다. Listing 10-8에서는 Point의 정의를 TU를 이용한 제네릭이 되도록 변경했습니다. 필드 x의 타입은 T이고, 필드 y의 타입은 U입니다:

Filename: src/main.rs

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

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

Listing 10-8: 두 타입을 이용한 제네릭이어서 xy가 다른 타입의 값일 수도 있는 Point

이제 위와 같은 모든 Point 인스턴스가 허용됩니다! 정의 부분에 여러분이 원하는 만큼 많은 수의 제네릭 타입 파라미터를 이용할 수 있지만, 몇몇 개보다 더 많이 이용하는 것은 읽고 이해하는 것을 어렵게 만듭니다. 여러분이 많은 수의 제네릭 타입을 필요로 하는 지점에 다다랐다면, 이는 아마도 여러분의 코드가 좀 더 작은 조각들로 나뉘는 재구조화가 필요할지도 모른다는 징조입니다.

열거형 정의 내에서 제네릭 데이터 타입 사용하기

구조체와 유사하게, 열거형도 그 variant 내에서 제네릭 데이터 타입을 갖도록 정의될 수 있습니다. 6장에서 표준 라이브러리가 제공하는 Option<T> 열거형을 이용해봤는데, 이제는 그 정의를 좀 더 잘 이해할 수 있겠지요. 다시 한번 봅시다:


# #![allow(unused_variables)]
#fn main() {
enum Option<T> {
    Some(T),
    None,
}
#}

달리 말하면, Option<T>T 타입에 제네릭인 열거형입니다. 이것은 두 개의 variant를 가지고 있습니다: 타입 T 값 하나를 들고 있는 Some, 그리고 어떠한 값도 들고 있지 않는 None variant 입니다. 표준 라이브러리는 구체적인 타입을 가진 이 열거형에 대한 값의 생성을 지원하기 위해서 딱 이 한 가지 정의만 가지고 있으면 됩니다. "옵션 값"의 아이디어는 하나의 명시적인 타입에 비해 더 추상화된 개념이고, 러스트는 이 추상화 개념을 수많은 중복 없이 표현할 수 있도록 해줍니다.

열거형은 또한 여러 개의 제네릭 타입을 이용할 수 있습니다. 우리가 9장에서 사용해본 Result 열거형의 정의가 한 가지 예입니다:


# #![allow(unused_variables)]
#fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
#}

Result 열거형은 TE, 두 개의 타입을 이용한 제네릭입니다. Result는 두 개의 variant를 가지고 있습니다: 타입 T의 값을 들고 있는 Ok, 그리고 타입 E의 값을 들고 있는 Err입니다. 이 정의는 성공하거나 (그래서 어떤 T 값을 반환하거나) 혹은 실패하는 (그래서 E 타입으로 된 에러를 반환하는) 연산이 필요한 어디에서든 편리하게 Result 열거형을 이용하도록 해줍니다. Listing 9-2에 우리가 파일을 열 때를 상기해보세요: 이 경우, 파일이 성공적으로 열렸을 때는 Tstd::fs::File 타입의 값이 채워지고 파일을 여는데 문제가 생겼을 때는 Estd::io::Error 타입으로 된 값이 채워졌습니다.

여러분의 코드에서 단지 들고 있는 값의 타입만 다른 여러 개의 구조체나 열거형이 있는 상황을 인지했다면, 우리가 함수 정의에서 제네릭 타입을 대신 도입하여 사용했던 것과 똑같은 절차를 통해 그러한 중복을 제거할 수 있습니다.

메소드 정의 내에서 제네릭 데이터 타입 사용하기

5장에서 했던 것과 유사하게, 정의부에 제네릭 타입을 갖는 구조체와 열거형 상의 메소드를 구현할 수도 있습니다. Listing 10-9는 우리가 Listing 10-6에서 정의했던 Point<T> 구조체를 보여주고 있습니다. 그러고 나서 필드 x의 값에 대한 참조자를 반환하는 x라는 이름의 메소드를 Point<T> 상에 정의했습니다:

Filename: src/main.rs

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

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Listing 10-9: T 타입의 x 필드에 대한 참조자를 반환하는 Point<T> 구조체 상에 x라는 이름의 메소드 정의

impl 바로 뒤에 T를 정의해야만 타입 Point<T> 메소드를 구현하는 중에 이를 사용할 수 있음을 주목하세요.

구조체 정의 내에서의 제네릭 타입 파라미터는 여러분이 구조체의 메소드 시그니처 내에서 사용하고 싶어하는 제네릭 타입 파라미터와 항상 같지 않습니다. Listing 10-10에서는 Listing 10-8에서의 Point<T, U> 구조체 상에 mixup 이라는 메소드를 정의했습니다. 이 메소드는 또다른 Point를 파라미터로 갖는데, 이는 우리가 호출하는 mixup 상의 selfPoint와 다른 타입을 가지고 있을 수도 있습니다. 이 메소드는 새로운 Point를 생성하는데 self Point로부터 (T 타입인) x 값을 가져오고, 파라미터로 넘겨받은 Point로부터 (W 타입인) y 값을 가져온 것입니다:

Filename: src/main.rs

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

impl<T, U> Point<T, U> {
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c'};

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

Listing 10-10: 구조체 정의에서와는 다른 제네릭 타입을 사용하는 메소드

main에서, 우리는 (5 값을 갖는) x에 대해 i32를, (10.4 값을 갖는) y에 대해 f64를 사용하는 Point를 정의했습니다. p2는 ("Hello" 값을 갖는) x에 대해 스트링 슬라이스를, (c 값을 갖는) y에 대해 char를 사용하는 Point입니다. p1상에서 인자로 p2를 넘기는 mixup 호출은 p3을 반환하는데, 이는 xp1으로부터 오기 때문에 xi32 타입을 갖게 될 것입니다. 또한 yp2로부터 오기 때문에 p3y에 대해 char 타입을 가지게 될 것입니다. println!p3.x = 5, p3.y = c를 출력하겠지요.

제네릭 파라미터 TUimpl 뒤에 선언되었는데, 이는 구조체 정의와 함께 사용되기 때문임을 주목하세요. 제네릭 파라미터 VWfn mixup 뒤에 선언되었는데, 이는 이들이 오직 해당 메소드에 대해서만 관련이 있기 때문입니다.

제네릭을 이용한 코드의 성능

여러분이 이 절을 읽으면서 제네릭 타입 파라미터를 이용한 런타임 비용이 있는지 궁금해하고 있을런지도 모르겠습니다. 좋은 소식을 알려드리죠: 러스트가 제네릭을 구현한 방식이 의미하는 바는 여러분이 제네릭 파라미터 대신 구체적인 타입을 명시했을 때와 비교해 전혀 느려지지 않을 것이란 점입니다!

러스트는 컴파일 타임에 제네릭을 사용하는 코드에 대해 단형성화(monomorphization) 를 수행함으로써 이러한 성능을 이루어 냈습니다. 단형성화란 제네릭 코드를 실제로 채워질 구체적인 타입으로 된 특정 코드로 바꾸는 과정을 말합니다.

컴파일러가 하는 일은 Listing 10-5에서 우리가 제네릭 함수를 만들 때 수행한 단계들을 반대로 한 것입니다. 컴파일러는 제네릭 코드가 호출되는 모든 곳을 살펴보고 제네릭 코드가 호출될 때 사용된 구체적인 타입에 대한 코드를 생성합니다.

표준 라이브러리의 Option 열거형을 사용하는 예제를 통해 알아봅시다:


# #![allow(unused_variables)]
#fn main() {
let integer = Some(5);
let float = Some(5.0);
#}

러스트가 이 코드를 컴파일할 때, 단형성화를 수행할 것입니다. 컴파일러는 Option에 넘겨진 값들을 읽고 두 종류의 Option<T>를 가지고 있다는 사실을 알게 됩니다: 하나는 i32이고 나머지 하나는 f64 이지요. 그리하여 컴파일러는 제네릭 정의를 명시적인 것들로 교체함으로써 Option<T>에 대한 제네릭 정의를 Option_i32Option_f64로 확장시킬 것입니다.

컴파일러가 생성한 우리의 단형성화된 버전의 코드는 아래와 같이 보이게 되는데, 컴파일러에 의해 생성된 구체화된 정의로 교체된 제네릭 Option이 사용되었습니다:

Filename: src/main.rs

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

우리는 제네릭을 사용하여 중복 없는 코드를 작성할 수 있고, 러스트는 이를 각 인스턴스에 대해 구체적인 타입을 갖는 코드로 컴파일할 것입니다. 이는 우리가 제네릭을 사용하는 데에 어떠한 런타임 비용도 없음을 의미합니다; 코드가 실행될 때, 손으로 각각 특정 정의를 중복시킨 것과 같이 실행될 것입니다. 단형성화의 과정은 러스트의 제네릭이 런타임에 극도로 효율적 이도록 만들어 주는 것입니다.

트레잇: 공유 동작을 정의하기

트레잇은 다른 종류의 추상화를 사용할 수 있도록 해줍니다: 이는 타입들이 공통적으로 갖는 동작에 대하여 추상화하도록 해줍니다. 트레잇(trait) 이란 러스트 컴파일러에게 특정한 타입이 갖고 다른 타입들과 함께 공유할 수도 있는 기능에 대해 말해줍니다. 우리가 제네릭 타입 파라미터를 사용하는 상황에서는, 컴파일 타임에 해당 제네릭 타입이 어떤 트레잇을 구현한 타입이어야 함을 명시하여, 그러한 상황에서 우리가 사용하길 원하는 동작을 갖도록 하기 위해 트레잇 바운드(trait bounds) 를 사용할 수 있습니다.

노트: 트레잇은 다른 언어들에서 '인터페이스(interface)'라고 부르는 기능과 유사하지만, 몇 가지 다른 점이 있습니다.

트레잇 정의하기

어떤 타입의 동작은 우리가 해당 타입 상에서 호출할 수 있는 메소드들로 구성되어 있습니다. 만일 우리가 서로 다른 타입에 대해 모두 동일한 메소드를 호출할 수 있다면 이 타입들은 동일한 동작을 공유하는 것입니다. 트레잇의 정의는 어떠한 목적을 달성하기 위해 필요한 동작의 집합을 정의하기 위해 메소드 시그니처들을 함께 묶는 방법입니다.

예를 들면, 다양한 종류와 양의 텍스트를 갖는 여러 가지의 구조체를 가지고 있다고 칩시다: NewsArticle 구조체는 세계의 특정한 곳에서 줄지어 들어오는 뉴스 이야기를 들고 있고, Tweet은 최대 140글자의 콘텐츠와 함께 해당 트윗이 리트윗인지 혹은 다른 트윗에 대한 답변인지와 같은 메타데이터를 가지고 있습니다.

우리는 NewsArticle 혹은 Tweet 인스턴스에 저장되어 있을 데이터에 대한 종합 정리를 보여줄 수 있는 미디어 종합기 라이브러리를 만들고 싶어 합니다. 각각의 구조체들이 가질 필요가 있는 동작은 정리해주기가 되어야 하며, 그래서 각 인스턴스 상에서 summary 메소드를 호출함으로써 해당 정리를 얻어낼 수 있어야 한다는 것입니다. Listing 10-11은 이러한 개념을 표현한 Summarizable 트레잇의 정의를 나타냅니다:

Filename: lib.rs


# #![allow(unused_variables)]
#fn main() {
pub trait Summarizable {
    fn summary(&self) -> String;
}
#}

Listing 10-11: summary 메소드에 의해 제공되는 동작으로 구성된 Summarizable 트레잇의 정의

trait 키워드 다음 트레잇의 이름, 위의 경우 Summarizable을 써서 트레잇을 선언했습니다. 중괄호 내에서는 이 트레잇을 구현하는 타입들이 가질 필요가 있는 동작들을 묘사한 메소드 시그니처들을 정의했는데, 위의 경우에는 fn summary(&self) -> String입니다. 메소드 시그니처 뒤에, 중괄호 내의 정의부를 제공하는 대신, 세미콜론을 집어넣었습니다. 그러면 이 트레잇을 구현하는 각 타입은 이 메소드의 본체에 대한 해당 타입 고유의 커스텀 동작을 제공해야 하는데, 컴파일러는 Summarizable 트레잇을 갖는 어떠한 타입이든 그에 대한 메소드 summary를 정확히 동일한 시그니처로 정의되도록 강제할 것입니다.

트레잇은 한 줄 당 하나의 메소드 시그니처와 각 줄의 끝에 세미콜론을 갖도록 함으로써, 본체 내에 여러 개의 메소드를 가질 수 있습니다.

특정 타입에 대한 트레잇 구현하기

Summariable 트레잇을 정의하였으니, 이제 우리의 미디어 종합기 내에서 이 동작을 갖길 원했던 타입들 상에 이 트레잇을 구현할 수 있습니다. Listing 10-12는 summary의 반환 값을 만들기 위해 헤드라인, 저자, 위치를 사용하는 NewsArticle 구조체 상의 Summariable 트레잇 구현을 보여줍니다. Tweet 구조체에 대해서는, 트윗 내용이 이미 140자로 제한되어 있음을 가정하고, summary를 정의하는 데 있어 사용자 이름과 해당 트윗의 전체 텍스트를 가지고 오는 선택을 했습니다.

Filename: lib.rs


# #![allow(unused_variables)]
#fn main() {
# pub trait Summarizable {
#     fn summary(&self) -> String;
# }
#
pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summarizable for NewsArticle {
    fn summary(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summarizable for Tweet {
    fn summary(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
#}

Listing 10-12: NewsArticleTweet 타입 상에서의 Summarizable 트레잇 구현

어떤 타입 상에서의 트레잇 구현은 트레잇과 관련이 없는 메소드를 구현하는 것과 유사합니다. 다른 점은 impl 뒤에 우리가 구현하고자 하는 트레잇 이름을 넣고, 그다음 for와 우리가 트레잇을 구현하고자 하는 타입의 이름을 쓴다는 것입니다. impl 블록 내에서는 트레잇 정의부가 정의한 바 있는 메소드 시그니처를 집어넣지만, 각 시그니처의 끝에 세미콜론을 집어넣는 대신 중괄호를 넣고 우리가 트레잇의 메소드가 특정한 타입에 대해서 갖기를 원하는 특정한 동작으로 메소드의 본체를 채웁니다.

트레잇을 한번 구현했다면, 트레잇의 일부가 아닌 메소드들을 호출했던 것과 동일한 방식으로 NewsArticleTweet의 인스턴스 상에서 해당 메소드들을 호출할 수 있습니다:

let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from("of course, as you probably already know, people"),
    reply: false,
    retweet: false,
};

println!("1 new tweet: {}", tweet.summary());

이 코드는 1 new tweet: horse_ebooks: of course, as you probably already know, people를 출력할 것입니다.

Listing 10-12에서 Summarizable 트레잇과 NewsArticleTweet 타입을 동일한 lib.rs 내에 정의했기 때문에, 이들이 모두 동일한 스코프 내에 있다는 점을 주목하세요. 만일 이 lib.rsaggregator라고 불리는 크레이트에 대한 것이고 누군가가 우리의 크레이트 기능에 더해 그들의 WeatherForecast 구조체에 대하여 Summarizable을 구현하기를 원한다면, 그들의 코드는 Listing 10-13과 같이 이를 구현하기 전에 먼저 Summarizable 트레잇을 그들의 스코프로 가져올 필요가 있습니다:

Filename: lib.rs

extern crate aggregator;

use aggregator::Summarizable;

struct WeatherForecast {
    high_temp: f64,
    low_temp: f64,
    chance_of_precipitation: f64,
}

impl Summarizable for WeatherForecast {
    fn summary(&self) -> String {
        format!("The high will be {}, and the low will be {}. The chance of
        precipitation is {}%.", self.high_temp, self.low_temp,
        self.chance_of_precipitation)
    }
}

Listing 10-13: 우리의 aggregator 크레이트로부터 다른 크레이트 내의 스코프로 Summarizable 트레잇을 가져오기

이 코드는 또한 Summariable이 공개 트레잇임을 가정하는데, 이는 Listing 10-11에서 trait 전에 pub 키워드를 집어넣었기 때문입니다.

트레잇 구현과 함께 기억할 한 가지 제한사항이 있습니다: 트레잇 혹은 타입이 우리의 크레이트 내의 것일 경우에만 해당 타입에서의 트레잇을 정의할 수 있습니다. 바꿔 말하면, 외부의 타입에 대한 외부 트레잇을 구현하는 것은 허용되지 않습니다. 예를 들어, Vec에 대한 Display 트레잇은 구현이 불가능한데, DisplayVec 모두 표준 라이브러리 내에 정의되어 있기 때문입니다. 우리의 aggregator 크레이트 기능의 일부로서 Tweet과 같은 커스텀 타입에 대한 Display와 같은 표준 라이브러리 트레잇을 구현하는 것은 허용됩니다. 또한 우리의 aggregator 크레이트 내에서 Vec에 대한 Summarizable을 구현하는 것도 가능한데, 이는 우리 크레이트 내에 Summarizable이 정의되어 있기 때문입니다. 이러한 제한은 고아 규칙(orphan rule) 이라고 불리는 것의 일부인데, 이는 타입 이론에 흥미가 있다면 찾아볼 수 있습니다. 간단하게 말하면, 부모 타입이 존재하지 않기 때문에 고아 규칙이라고 부릅니다. 이 규칙이 없다면, 두 크레이트는 동일한 타입에 대해 동일한 트레잇을 구현할 수 있게 되고, 이 두 구현체가 충돌을 일으킬 것입니다: 러스트는 어떤 구현을 이용할 것인지 알지 못할 것입니다. 러스트가 고아 규칙을 강제하기 때문에, 다른 사람의 코드는 여러분의 코드를 망가뜨리지 못하고 반대의 경우도 마찬가지입니다.

기본 구현

종종 모든 타입 상에서의 모든 구현체가 커스텀 동작을 정의하도록 하는 대신, 트레잇의 몇몇 혹은 모든 메소드들에 대한 기본 동작을 갖추는 것이 유용할 수 있습니다. 특정한 타입에 대한 트레잇을 구현할 때, 각 메소드의 기본 동작을 유지하거나 오버라이드(override)하도록 선택할 수 있습니다.

Listing 10-14는 우리가 Listing 10-11에서 한 것과 같이 메소드 시그니처를 정의만 하는 선택 대신 Summarizable 트레잇의 summary 메소드에 대한 기본 스트링을 명시하는 선택을 하는 방법을 보여줍니다:

Filename: lib.rs


# #![allow(unused_variables)]
#fn main() {
pub trait Summarizable {
    fn summary(&self) -> String {
        String::from("(Read more...)")
    }
}
#}

Listing 10-14: summary 메소드의 기본 구현을 포함한 Summarizable 트레잇의 정의

만일 우리가 Listing 10-12에서 한 것과 같은 커스텀 구현을 정의하는 대신 NewsArticle의 인스턴스를 정리하기 위해 이 기본 구현을 사용하고자 한다면, 빈 impl 블록을 명시하면 됩니다:

impl Summarizable for NewsArticle {}

비록 NewsArticle에 대한 summary 메소드를 직접 정의하는 선택을 더 이상 하지 않았더라도, summary 메소드가 기본 구현을 갖고 있고 NewsArticleSummariable 트레잇을 구현하도록 명시했기 때문에, 우리는 여전히 newsArticle의 인스턴스 상에서 summary 메소드를 호출할 수 있습니다:

let article = NewsArticle {
    headline: String::from("Penguins win the Stanley Cup Championship!"),
    location: String::from("Pittsburgh, PA, USA"),
    author: String::from("Iceburgh"),
    content: String::from("The Pittsburgh Penguins once again are the best
    hockey team in the NHL."),
};

println!("New article available! {}", article.summary());

위의 코드는 New article available! (Read more...)를 출력합니다.

Summarizable 트레잇이 summary 에대한 기본 구현을 갖도록 변경하는 것은 Listing 10-12의 Tweet이나 Listing 10-13의 WeatherForecast 상에서의 Summarizable 구현에 대한 어떤 것도 바꾸도록 요구하지 않습니다: 기본 구현을 오버라이딩 하기 위한 문법은 기본 구현이 없는 트레잇 메소드를 구현하기 위한 문법과 정확히 동일합니다.

기본 구현은 동일한 트레잇 내의 다른 메소드들을 호출하는 것이 허용되어 있는데, 심지어 그 다른 메소드들이 기본 구현을 갖고 있지 않아도 됩니다. 이러한 방식으로, 트레잇은 수많은 유용한 기능을 제공하면서도 다른 구현자들이 해당 트레잇의 작은 일부분만 구현하도록 요구할 수 있습니다. 우리는 Summarizable 트레잇이 구현이 필요한 author_summary 메소드도 갖도록 하여, summary 메소드가 author_summary 메소드를 호출하는 기본 구현을 갖는 형태를 선택할 수도 있습니다:


# #![allow(unused_variables)]
#fn main() {
pub trait Summarizable {
    fn author_summary(&self) -> String;

    fn summary(&self) -> String {
        format!("(Read more from {}...)", self.author_summary())
    }
}
#}

이 버전의 Summarizable을 사용하기 위해서는, 어떤 타입에 대한 이 트레잇을 구현할 때 author_summary만 정의하면 됩니다:

impl Summarizable for Tweet {
    fn author_summary(&self) -> String {
        format!("@{}", self.username)
    }
}

일단 author_summary를 정의하면, Tweet 구조체의 인스턴스 상에서 summary를 호출할 수 있으며, summary의 기본 구현이 우리가 제공한 author_summary의 정의부를 호출할 것입니다.

let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from("of course, as you probably already know, people"),
    reply: false,
    retweet: false,
};

println!("1 new tweet: {}", tweet.summary());

위의 코드는 1 new tweet: (Read more from @horse_ebooks...)를 출력할 것입니다.

오버라이딩된 구현으로부터 기본 구현을 호출하는 것은 불가능하다는 점을 기억해주세요.

트레잇 바운드

이제 트레잇을 정의하고 어떤 타입들에 대해 이 트레잇을 구현해봤으니, 제네릭 타입 파라미터를 이용하는 트레잇을 사용할 수 있습니다. 우리는 제네릭 타입에 제약을 가하여 이 제네릭 타입이 어떠한 타입이든 되기 보다는, 이 제네릭 타입이 특정한 트레잇을 구현하여 이 타입들이 가지고 있을 필요가 있는 동작을 갖고 있도록 타입들로 제한함을 컴파일러가 확신하도록 할 수 있습니다.

예를 들면, Listing 10-12에서는 NewsArticleTweet 타입에 대하여 Summarizable 트레잇을 구현했습니다. 우리는 파라미터 item 상에서 summary 메소드를 호출하는 함수 notify를 정의할 수 있는데, 이 item은 제네릭 타입 T의 값입니다. 에러없이 item 상에서 summary를 호출하기 위해서는, T에 대한 트레잇 바운드를 사용하여 itemSummarizable 트레잇을 반드시 구현한 타입이어야 함을 특정할 수 있습니다:

pub fn notify<T: Summarizable>(item: T) {
    println!("Breaking news! {}", item.summary());
}

트레잇 바운드는 제네릭 타입 파라미터의 선언부와 함께, 꺾쇠 괄호 내에 콜론 뒤에 옵니다. T 상에서의 트레잇 바운드이므로, 우리는 notify를 호출하여 NewsArticle이나 Tweet의 어떠한 인스턴스라도 넘길 수 있습니다. 우리의 aggregator 크레이트를 사용하는 Listing 10-13의 외부 코드도 우리의 notify 함수를 호출하여 WeatherForecast의 인스턴스를 넘길 수 있는데, 이는 WeatherForecast 또한 Summariable을 구현하였기 때문입니다. String이나 i32 같은 어떠한 다른 타입을 가지고 notify를 호출하는 코드는 컴파일되지 않을 것인데, 그 이유는 그러한 타입들이 Summarizable을 구현하지 않았기 때문입니다.

+를 이용하면 하나의 제네릭 타입에 대해 여러 개의 트레잇 바운드를 특정할 수 있습니다. 만일 함수 내에서 타입 T에 대해 summary 메소드 뿐만 아니라 형식화된 출력을 사용하길 원한다면, 트레잇 바운드 T: Summarizable + Display를 이용할 수 있습니다. 이는 TSummariableDisplay 둘다 구현한 어떤 타입이어야 함을 의미합니다.

여러 개의 제네릭 타입 파라미터를 가진 함수들에 대하여, 각 제네릭은 고유의 트레잇 바운드를 가집니다. 함수 이름과 파라미터 리스트 사이의 꺾쇠 괄호 내에 많은 수의 트레잇 바운드 정보를 특정하는 것은 코드를 읽기 힘들게 만들 수 있으므로, 함수 시그니처 뒤에 where 절 뒤로 트레잇 바운드를 옮겨서 특정하도록 해주는 대안 문법이 있습니다. 따라서 아래와 같은 코드 대신:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 {

where 절을 이용하여 아래와 같이 작성할 수 있습니다:

fn some_function<T, U>(t: T, u: U) -> i32
    where T: Display + Clone,
          U: Clone + Debug
{

함수 이름, 파라미터 리스트, 그리고 반환 타입이 서로 가까이 있도록 하여, 이쪽이 덜 어수선하고 이 함수의 시그니처를 많은 트레잇 바운드를 가지고 있지 않은 함수처럼 보이도록 만들어 줍니다.

트레잇 바운드를 사용하여 largest 함수 고치기

따라서 여러분이 어떤 제네릭 상에서 어떤 트레잇으로 정의된 동작을 이용하기를 원하는 어떤 경우이든, 여러분은 해당 제네릭 타입 파라미터의 타입내에 트레잇 바운드를 명시할 필요가 있습니다. 이제 우리는 Listing 10-5에서 제네릭 타입 파라미터를 사용하는 largest 함수의 정의를 고칠 수 있습니다! 우리가 그 코드를 치워뒀을 때, 아래와 같은 에러를 봤었지요:

error[E0369]: binary operation `>` cannot be applied to type `T`
  |
5 |         if item > largest {
  |            ^^^^
  |
note: an implementation of `std::cmp::PartialOrd` might be missing for `T`

largest의 본체 내에서 큰 부등호 연산자를 사용하여 타입 T의 두 값을 비교할 수 있길 원했습니다. 이 연산자는 표준 라이브러리 트레잇인 std::cmp::PartialOrd 상에서 기본 메소드로 정의되어 있습니다. 따라서 큰 부등호 연산자를 사용할 수 있도록 하기 위해서는, T에 대한 트레잇 바운드 내에 PartialOrd를 특정하여 largest 함수가 비교 가능한 어떤 타입의 슬라이스에 대해 작동하도록 할 필요가 있습니다. PartialOrd는 프렐루드(prelude)에 포함되어 있기 때문에 따로 스코프 내로 가져올 필요는 없습니다.

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

이 코드를 컴파일하면, 다른 에러를 얻게 됩니다:

error[E0508]: cannot move out of type `[T]`, a non-copy array
 --> src/main.rs:4:23
  |
4 |     let mut largest = list[0];
  |         -----------   ^^^^^^^ cannot move out of here
  |         |
  |         hint: to prevent move, use `ref largest` or `ref mut largest`

error[E0507]: cannot move out of borrowed content
 --> src/main.rs:6:9
  |
6 |     for &item in list.iter() {
  |         ^----
  |         ||
  |         |hint: to prevent move, use `ref item` or `ref mut item`
  |         cannot move out of borrowed content

이 에러에 대한 열쇠는 cannot move out of type [T], a non-copy array에 있습니다. largest 함수의 제네릭 없는 버전에서, 우리는 고작 가장 큰 i32 혹은 char를 찾는 시도만 했습니다. 4장에서 논의한 바와 같이, 고정된 크기를 갖는 i32char와 같은 타입들은 스택에 저장될 수 있으며, 따라서 이 타입들은 Copy 트레잇을 구현하고 있습니다. 우리가 largest 함수를 제네릭으로 바꿨을 때, 이제는 list 파라미터가 Copy 트레잇을 구현하지 않은 타입을 가질 가능성도 생기는데, 이는 곧 list[0]의 값을 largest 변수로 소유권을 옮기지 못할 것이라는 의미입니다.

만약 이 코드를 오직 Copy가 구현된 타입들을 가지고 호출하도록 하는 것만 원한다면, T의 트레잇 바운드에 Copy를 추가할 수 있습니다! Listing 10-15는 largest로 넘겨지는 슬라이스 내의 값의 타입이 i32char처럼 PartialOrdCopy 트레잇 모두를 구현했을 때에 한하여 컴파일되는 제네릭 largest 함수의 완전체 코드를 보여줍니다:

Filename: src/main.rs

use std::cmp::PartialOrd;

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let result = largest(&numbers);
    println!("The largest number is {}", result);

    let chars = vec!['y', 'm', 'a', 'q'];

    let result = largest(&chars);
    println!("The largest char is {}", result);
}

Listing 10-15: PartialOrdCopy 트레잇을 구현한 어떠한 제네릭 타입 상에서 동작하는 largest 함수의 동작 가능한 정의

만일 우리의 largest 함수를 Copy 트레잇을 구현한 타입에 대한 것으로만 제한하길 원치 않는다면, TCopy 대신 Clone 트레잇 바운드를 갖도록 명시하여 largest 함수가 소유권을 갖길 원하는 경우 슬라이스의 각 값이 복제되도록 할 수도 있습니다. 그러나 clone 함수를 이용한다는 것은 더 많은 힙 할당을 할 수 있다는 것이고, 힙 할당은 많은 양의 데이터에 대해서 동작할 경우 느릴 수 있습니다. largest를 구현하는 또다는 방법은 함수가 슬라이스 내의 T 값에 대한 참조자를 반환하도록 하는 것입니다. 만약 반환 타입을 T 대신 &T로 바꾸고 함수의 본체가 참조자를 반환하도록 바꾼다면, Clone이나 Copy 트레잇 바운드도 필요치 않으며 어떠한 힙 할당도 하지 않게 될 것입니다. 여러분이 직접 이 대안 해결책을 구현해보세요!

트레잇과 트레잇 바운드는 중복을 제거하기 위하여 제네릭 타입 파라미터를 사용하는 코드를 작성할 수 있도록 해주지만, 여전히 컴파일러에게 해당 제네릭 타입이 어떤 동작을 할 필요가 있는지를 정확히 명시하도록 해줍니다. 컴파일러에게 트레잇 바운드를 제공하기 때문에, 우리 코드와 함께 이용되는 모든 구체적인 타입들이 정확한 동작을 제공하는지를 확인할 수 있습니다. 동적 타입 언어에서는, 어떤 타입에 대해 어떤 메소드를 호출하는 시도를 했는데 해당 타입이 그 메소드를 구현하지 않았다면, 런타임에 에러를 얻게 됩니다. 러스트는 이러한 에러들을 컴파일 타임으로 옮겨서 우리의 코드가 실행 가능하기 전에 그 문제들을 해결하도록 우리를 강제합니다. 이에 더해서, 우리는 런타임에 해당 동작에 대한 검사를 하는 코드를 작성할 필요가 없는데, 우리는 이미 컴파일 타임에 이를 확인했기 때문이며, 이는 제네릭의 유연성을 포기하지 않고도 다른 언어들에 비해 성능을 향상시킵니다.

우리가 심지어 아직 알아채지도 못한 라이프타임(lifetime) 이라 불리는 또다른 종류의 제네릭이 있습니다. 라이프타임은 어떤 타임이 우리가 원하는 동작을 갖도록 확신하는데 도움을 주기 보다는, 참조자들이 우리가 원하는 만큼 오랫동안 유효한지를 확신하도록 도와줍니다. 라이프타임이 어떤 식으로 그렇게 하는지를 배워봅시다.

라이프타임을 이용한 참조자 유효화

4장에서 참조자에 대한 이야기를 할 때, 중요한 디테일을 한 가지 남겨두었습니다: 러스트에서 모든 참조자는 라이프타임(lifetime) 을 갖는데, 이는 해당 참조자가 유효한 스코프입니다. 대부분의 경우에서 타입들이 추론되는 것과 마찬가지로, 대부분의 경우에서 라이프타임 또한 암묵적이며 추론됩니다. 여러 가지 타입이 가능하기 때문에 우리가 타입을 명시해야 하는 때와 비슷하게, 참조자의 라이프타임이 몇몇 다른 방식으로 연관될 수 있는 경우들이 있으므로, 러스트는 우리에게 제네릭 라이프타임 파라미터를 이용하여 이 관계들을 명시하길 요구하여 런타임에 실제 참조자가 확실히 유효하도록 확신할 수 있도록 합니다.

네 그렇습니다. 이러한 개념은 다소 흔치 않으며, 여러분들이 다른 프로그래밍 언어에서 사용해온 도구들과는 다른 것입니다. 몇 가지 측면에서, 라이프타임은 러스트의 가장 독특한 기능입니다.

라이프타임은 이 장에서 전체를 다룰 수 없는 큰 주제이므로, 이 장에서는 여러분이 이 개념에 친숙해질 수 있도록 여러분이 라이프타임 문법을 맞닥뜨릴 흔한 경우에 대해 다룰 것입니다. 19장에서는 라이프타임이 할 수 있는 좀 더 상급 정보를 다룰 것입니다.

라이프타임은 댕글링 참조자를 방지합니다

라이프타임의 주목적은 댕글링 참조자(dangling reference)를 방지하는 것인데, 댕글링 참조자는 프로그램이 우리가 참조하기로 의도한 데이터가 아닌 다른 데이터를 참조하는 원인이 됩니다. Listing 10-16의 프로그램과 같이 외부 스코프와 내부 스코프를 가진 프로그램을 생각해봅니다. 외부 스코프는 r이라는 이름의 변수를 초기값 없이 선언하였고, 내부 스코프는 x라는 이름의 변수를 초기값 5와 함께 선언했습니다. 내부 스코프 내에서, x의 참조자를 r에 대입하도록 시도했습니다. 그 후 내부 스코프는 끝났고, r의 값을 출력하도록 시도했습니다:

{
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {}", r);
}

Listing 10-16: 스코프 밖으로 벗어난 값에 대한 참조자를 사용하는 시도

초기화되지 않은 변수는 사용할 수 없습니다

다음에 나올 몇 가지 예제는 초기값을 주지 않고 변수를 선언하고 있으며, 따라서 해당 변수의 이름이 외부 스코프에 존재하고 있습니다. 이는 러스트가 널(null) 값을 갖지 않는다는 개념과 충돌을 일으키는 것처럼 보일지도 모릅니다. 그러나, 우리가 값을 제공하기 전에 변수를 사용하고자 시도하면, 컴파일 에러가 나올 것입니다. 시도해 보세요!

이 코드를 컴파일하면, 다음과 같은 에러가 나타날 것입니다:

error: `x` does not live long enough
   |
6  |         r = &x;
   |              - borrow occurs here
7  |     }
   |     ^ `x` dropped here while still borrowed
...
10 | }
   | - borrowed value needs to live until here

변수 x는 "충분히 오래 살지 못한다(does not live long enough)"고 합니다. 왜 안될까요? x는 7번 라인의 닫는 중괄호 기호에 도달했을 때 내부 스코프가 끝나면서 스코프 밖으로 벗어날 것입니다. 그러나 r은 외부 스코프에 대해 유효합니다; 이쪽의 스코프가 더 크고 우리는 이쪽이 "더 오래 산다"라고 말합니다. 만일 러스트가 이 코드를 작동하도록 허용한다면, rx가 스코프 밖으로 벗어났을 때 할당이 해제되는 메모리를 참조하게 될 것이고, r을 가지고 시도하려 했던 어떤 것이든 정확히 동작하지 않게 될 것입니다. 그렇다면 러스트는 이 코드가 허용되어서는 안 된다는 것을 어떻게 결정할까요?

빌림 검사기(Borrow checker)

빌림 검사기(borrow checker) 라고 불리는 컴파일러의 부분이 모든 빌림이 유효한지를 결정하기 위해 스코프를 비교합니다. Listing 10-17은 변수들의 라이프타임을 보여주는 주석과 함께 Listing 10-16과 동일한 예제를 보여줍니다:

{
    let r;         // -------+-- 'a
                   //        |
    {              //        |
        let x = 5; // -+-----+-- 'b
        r = &x;    //  |     |
    }              // -+     |
                   //        |
    println!("r: {}", r); // |
                   //        |
                   // -------+
}

Listing 10-17: 각각 'a'b로 명명된 rx의 라이프타임에 대한 주석

우리는 r의 라이프타임을 'a라고 명명하였고, x의 라이프타임을 'b라고 명명하였습니다. 보시다시피, 내부의 'b 블록은 외부의 'a 라이프타임 블록에 비해 훨씬 작습니다. 컴파일 타임에서, 러스트는 두 라이프타임의 크기를 비교하고 r'a 라이프타임을 가지고 있지만, 'b 라이프타임을 가지고 있는 어떤 오브젝트를 참조하고 있음을 보게 됩니다. 'b 라이프타임이 'a 라이프타임에 비해 작기 때문에 러스트 컴파일러는 이 프로그램을 거부합니다: 참조자의 주체가 참조자만큼 오래 살지 못하고 있으니까요.

댕글링 참조자를 만드는 시도가 없고 에러 없이 컴파일되는 Listing 10-18의 예제를 살펴봅시다:


# #![allow(unused_variables)]
#fn main() {
{
    let x = 5;            // -----+-- 'b
                          //      |
    let r = &x;           // --+--+-- 'a
                          //   |  |
    println!("r: {}", r); //   |  |
                          // --+  |
}                         // -----+
#}

Listing 10-18: 데이터가 참조자에 비해 더 긴 라이프타임을 갖고 있기 때문에 유효한 참조자

여기서 x는 라이프타임 'b를 갖고 있는데, 위의 경우 'a에 비해 더 큽니다. 이는 rx를 참고할 수 있음을 의미합니다: 러스트는 r의 참조자가 x가 유효한 동안 언제나 유효할 것이라는 점을 알고 있습니다.

지금까지 참조자의 라이프타임이 구체적인 예제 어디에 나오는지를 보았고 러스트가 어떻게 라이프타임을 분석하여 참조자가 항상 유효하도록 확신시키는지를 논의했으니, 이제 함수의 내용물 내에 있는 파라미터와 반환 값에 대한 제네릭 라이프타임에 대하여 이야기해 봅시다.

함수에서의 제네릭 라이프타임

두 스트링 슬라이스 중에서 긴 쪽을 반환하는 함수를 작성해 봅시다. 이 함수에 두 개의 스트링 슬라이스를 넘겨서 호출할 수 있기를 원하고, 스트링 슬라이스를 반환하기를 원합니다. Listing 10-19의 코드는 longest 함수를 구현하면 The longest string is abcd를 출력해야 합니다:

Filename: src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

Listing 10-19: 두 스트링 슬라이스 중 긴 쪽을 찾기 위해 longest 함수를 호출하는 main 함수

longest 함수가 인자의 소유권을 얻는 것을 원치 않기 때문에 스트링 슬라이스들을 (4장에서 이야기했던 것처럼 이들은 참조자입니다) 파라미터로서 갖는 함수를 원한다는 점을 주목하세요. 우리는 함수가 String의 슬라이스 (이는 변수 string1의 타입입니다)는 물론 스트링 리터럴 (이는 변수 string2가 담고 있는 것이지요) 또한 받아들일 수 있기를 원하고 있습니다.

왜 이들이 우리가 원하는 인자 들인 지에 대한 더 많은 논의에 대해서는 4장의 "인자로서의 스트링 슬라이스"를 참조하세요.

만일 Listing 10-20에서 보는 바와 같이 longest 함수를 구현하는 시도를 한다면, 이는 컴파일되지 않을 것입니다:

Filename: src/main.rs

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Listing 10-20: 두 스트링 슬라이스 중 긴 쪽을 반환하는 longest 함수의 구현체, 그러나 아직 컴파일되지 않음

대신 우리는 라이프타임에 대해 이야기하는 다음과 같은 에러를 얻습니다:

error[E0106]: missing lifetime specifier
   |
1  | fn longest(x: &str, y: &str) -> &str {
   |                                 ^ expected lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the
   signature does not say whether it is borrowed from `x` or `y`

이 도움말은 반환 타입에 대하여 제네릭 라이프타임 파라미터가 필요하다는 것을 말해주고 있는데, 왜냐하면 반환되는 참조자가 x를 참조하는지 혹은 y를 참조하는지를 러스트가 말할 수 없기 때문입니다. 사실, 우리 또한 모르는데, 이 함수의 본체 내의 if 블록은 x의 참조자를 반환하고 else 블록은 y의 참조자를 반환하기 때문입니다!

우리가 이 함수를 정의하고 있는 시점에서, 우리는 이 함수에 넘겨지게 될 구체적인 값을 모르므로, if 케이스가 실행될지 혹은 else 케이스가 실행될지는 알 수 없습니다. 또한 함수에 넘겨지게 될 참조자의 구체적인 라이프타임을 알지 못하므로, 우리가 반환하는 참조자가 항상 유효한지를 결정하기 위해서 Listing 10-17과 10-18에서 했던 것과 같이 스코프를 살펴볼 수도 없습니다. 빌림 검사기 또한 이를 결정할 수 없는데, 그 이유는 xy의 라이프타임이 반환 값의 라이프타임과 어떻게 연관되어 있는지 알지 못하기 때문입니다. 우리는 참조자들 간의 관계를 정의하는 제네릭 라이프타임 파라미터를 추가하여 빌림 검사기가 분석을 수행할 수 있도록 할 것입니다.

라이프타임 명시 문법

라이프타임 명시는 연관된 참조자가 얼마나 오랫동안 살게 되는지를 바꾸지는 않습니다. 함수의 시그니처가 제네릭 타입 파라미터를 특정할 때 이 함수가 어떠한 타입이든 허용할 수 있는 것과 같은 방식으로, 함수의 시그니처가 제네릭 라이프타임 파라미터를 특정할 때라면 이 함수는 어떠한 라이프타임을 가진 참조자라도 허용할 수 있습니다. 라이프타임 명시가 하는 것은 여러 개의 참조자에 대한 라이프타임들을 서로 연관 짓도록 하는 것입니다.

라이프타임 명시는 약간 독특한 문법을 갖고 있습니다: 라이프타임 파라미터의 이름은 어퍼스트로피 '로 시작해야 합니다. 라이프타임 파라미터의 이름은 보통 모두 소문자이며, 제네릭 타입과 비슷하게 그들의 이름은 보통 매우 짧습니다. 'a는 대부분의 사람들이 기본적으로 사용하는 이름입니다. 라이프타임 파라미터 명시는 참조자의 & 뒤에 오며, 공백 문자가 라이프타임 명시와 참조자의 타입을 구분해줍니다.

여기 몇 가지 예제가 있습니다: 라이프타임 파라미터가 없는 i32에 대한 참조자, 'a라고 명명된 라이프타임 파라미터를 가지고 있는 i32에 대한 참조자, 그리고 역시 라이프타임 'a를 갖고 있는 i32에 대한 가변 참조자입니다:

&i32        // a reference
&'a i32     // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime

스스로에 대한 하나의 라이프타임 명시는 큰 의미를 가지고 있지 않습니다: 라이프타임 명시는 러스트에게 여러 개의 참조자에 대한 제네릭 라이프타임 파라미터가 서로 어떻게 연관되는지를 말해줍니다. 만일 라이프타임 'a를 가지고 있는 i32에 대한 참조자인 first를 파라미터로, 그리고 또한 라이프타임 'a를 가지고 있는 i32에 대한 또 다른 참조자인 second를 또 다른 파라미터로 가진 함수가 있다면, 이 두 개의 같은 이름을 가진 라이프타임 명시는 참조자 firstsecond가 돌다 동일한 제네릭 라이프타임만큼 살아야 한다는 것을 가리킵니다.

함수 시그니처 내의 라이프타임 명시

우리가 작업하고 있던 longest 함수의 내용 중에서 라이프타임 명시 부분을 살펴봅시다. 제네릭 타입 파라미터와 마찬가지로, 제네릭 라이프타임 파라미터도 함수 이름과 파라미터 리스트 사이에 꺾쇠괄호를 쓰고 그 안에 정의가 되어야 합니다. 우리가 파라미터들과 반환 값에서의 참조자들에 대해 러스트에게 말해주고 싶은 제약사항은 그들이 모두 동일한 라이프타임을 갖고 있어야 한다는 것인데, 이는 Listing 10-21에서 보는 바와 같이 우리가 'a라고 명명하여 각각의 참조자에 추가할 것입니다:

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
#}

Listing 10-21: 시그니처 내의 모든 참조자들이 동일한 라이프타임 'a를 가지고 있어야 함을 특정한 longest 함수 정의

이는 컴파일될 것이고 Listing 10-19에 있는 main 함수에서 사용되었을 때 우리가 원하는 결과를 만들어줄 것입니다.

이 함수 시그니처는 이제 어떤 라이프타임 'a에 대하여, 이 함수는 두 개의 파라미터를 갖게 될 것인데, 두 개 모두 적어도 라이프타임 'a만큼 살아있는 스트링 슬라이스임을 말해줍니다. 이 함수는 또한 적어도 라이프타임 'a만큼 살아있는 스트링 슬라이스를 반환할 것입니다. 이는 러스트에게 우리가 강제하고 싶은 것을 말해주는 계약입니다.

이 함수 시그니처 내에 라이프타임 파라미터를 특정함으로써, 우리는 함수에 넘겨지거나 반환되는 어떠한 값들의 라이프타임도 바꾸지 않지만, 이 계약에 부합하지 않는 어떠한 값들도 빌림 검사기에 의해 거부되어야 함을 말해주는 것입니다. 이 함수는 xy가 정확히 얼마나 오래 살게 될지 알지 못하지만 (혹은 알 필요가 없지만), 다만 이 시그니처를 만족시킬 'a에 대입될 수 있는 어떤 스코프가 있음을 알아야 할 필요가 있을 뿐입니다.

함수 안에 라이프타임을 명시할 때, 이 명시는 함수 시그니처에 붙어 있으며, 함수의 본체 내에의 어떠한 코드에도 붙어있지 않습니다. 이는 러스트가 다른 도움 없이 함수 내의 코드를 분석할 수 있지만, 함수가 그 함수 밖의 코드에서의 참조자를 가지고 있을 때, 인자들 혹은 반환 값들의 라이프타임이 함수가 호출될 때마다 달라질 가능성이 있기 때문입니다. 이는 러스트가 발견해내기에는 너무나 비용이 크고 종종 불가능할 것입니다. 이 경우, 우리는 스스로 라이프타임을 명시할 필요가 있습니다.

구체적인 참조자들이 longest로 넘겨질 때, 'a에 대입되게 되는 구체적인 라이프타임은 y의 스코프와 겹치는 x 스코프의 부분입니다. 스코프는 언제나 중첩되기 때문에, 이것이 제네릭 라이프타임 'a이다라고 말하는 또 다른 방법은 xy의 라이프타임 중에서 더 작은 쪽과 동일한 구체적인 라이프타임을 구하는 것일 겁니다. 반환되는 참조자에 대해서도 같은 라이프타임 파라미터인 'a를 명시했으므로, 반환되는 참조자도 xy의 라이프타임 중 짧은 쪽만큼은 길게 유효함을 보장할 것입니다.

서로 다른 구체적인 라이프타임을 가진 참조자들을 넘김으로써 이것이 longest 함수의 사용을 어떻게 제한하는지 봅시다. Listing 10-22는 아무 언어에서나 여러분의 직관에 부합될 간단한 예제입니다: string1은 외부 스코프가 끝날 때까지 유효하고 string2는 내부 스코프가 끝날 때까지 유효하며, result는 내부 스코프가 끝날 때까지 유효한 무언가를 참조합니다. 빌림 검사기는 이 코드를 승인합니다; 이는 컴파일되며 실행했을 때 The longest string is long string is long를 출력합니다:

Filename: src/main.rs

# fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
#     if x.len() > y.len() {
#         x
#     } else {
#         y
#     }
# }
#
fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result);
    }
}

Listing 10-22: 서로 다른 구체적인 라이프타임을 가진 String 값의 참조자들을 이용한 longest 함수의 사용

다음으로, result의 참조자의 라이프타임이 두 인자들의 라이프타임보다 작아야 함을 보여줄 예제를 시도해봅시다. 우리는 result의 선언부를 내부 스코프 밖으로 옮길 것이지만, result 변수에 대한 값의 대입은 string2가 있는 스코프 내에 남겨둘 것입니다. 다음으로, result를 이용하는 println! 구문을 내부 스코프 바깥에, 내부 스코프가 끝나는 시점으로 옮기겠습니다. 이렇게 수정한 Listing 10-23의 코드는 컴파일되지 않을 것입니다:

Filename: src/main.rs

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {}", result);
}

Listing 10-23: string2가 스코프 밖으로 벗어난 후에 result를 사용하고자 하는 시도는 컴파일되지 않습니다

만일 이를 컴파일하고자 시도하면, 다음과 같은 에러를 얻습니다:

error: `string2` does not live long enough
   |
6  |         result = longest(string1.as_str(), string2.as_str());
   |                                            ------- borrow occurs here
7  |     }
   |     ^ `string2` dropped here while still borrowed
8  |     println!("The longest string is {}", result);
9  | }
   | - borrowed value needs to live until here

이 에러는 resultprintln!에서 유효하기 위해서는. string2가 외부 스코프의 끝까지 유효할 필요가 있음을 말해줍니다. 러스트는 이를 알고 있는데, 그 이유는 우리가 함수의 파라미터들과 반환 값에 대해 동일한 라이프타임 파라미터 'a를 명시했기 때문입니다.

우리는 인간으로서 이 코드를 살펴볼 수 있고 string1이 더 길기 때문에 resultstring1의 참조자를 담게 될 것이라는 점을 알 수 있습니다. string1이 스코프 밖으로 아직 벗어나지 않았기 때문에, string1의 참조자는 println! 구문에서 여전히 유효할 것입니다. 그렇지만, 우리가 러스트에게 라이프타임 파라미터를 가지고 말해준 것은 longest 함수에 의해 반환되는 참조자의 라이프타임이 인자로 넘겨준 라이프타임들 중 작은 쪽과 동일하다는 것이었지요. 따라서, 빌림 검사기는 잠재적으로 유효하지 않은 참조자를 가질 수 있는 문제로 인해 Listing 10-23의 코드를 허용하지 않습니다.

longest 함수에 넘겨질 참조자들의 값과 라이프타임들, 그리고 반환된 참조자를 어떻게 이용하는지를 다양화하여 더 많은 실험들을 디자인해 시도해보세요. 컴파일하기 전에 여러분의 실험이 빌림 검사기를 통과할지 안 할지에 대한 가설을 세워보고, 여러분이 맞았는지 확인해보세요!

라이프타임의 측면에서 생각하기

라이프타임 파라미터를 특정하는 정확한 방법은 여러분의 함수가 어떤 일을 하고 있는가에 따라 달린 문제입니다. 예를 들면, longest 함수의 구현을 제일 긴 스트링 슬라이스 대신 항상 첫 번째 인자를 반환하도록 바꾸었다면, y 파라미터에 대한 라이프타임을 특정할 필요는 없을 것입니다. 아래 코드는 컴파일됩니다:

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}
#}

이 예제에서, 파라미터 x와 반환 값에 대한 라이프타임 파라미터 'a는 특정하였지만, 파라미터 y는 특정하지 않았는데, 그 이유는 y의 라이프타임이 x 혹은 반환 값의 라이프타임과 어떠한 관련도 없기 때문입니다.

함수로부터 참조자를 반환할 때, 반환 타입에 대한 라이프타임 파라미터는 인자 중 하나의 라이프타임 파라미터와 일치할 필요가 있습니다. 만일 반환되는 참조가 인자들 중 하나를 참조하지 않는다면, 다른 유일한 가능성은 이 함수 내에서 생성된 값을 참조하는 경우인데, 이 값은 함수가 끝나는 시점에서 스코프 밖으로 벗어나기 때문에 댕글링 참조자가 될 것입니다. longest 함수에 대한 아래와 같은 구현 시도는 컴파일되지 않습니다:

Filename: src/main.rs

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}

우리가 반환 타입에 대해 라이프타임 파라미터 'a를 특정했을지라도, 이러한 구현은 컴파일에 실패하게 되는데 이는 반환되는 값의 라이프타임이 파라미터의 라이프타임과 아무런 관련이 없기 때문입니다. 여기 우리가 얻게 되는 에러 메시지를 보시죠:

error: `result` does not live long enough
  |
3 |     result.as_str()
  |     ^^^^^^ does not live long enough
4 | }
  | - borrowed value only lives until here
  |
note: borrowed value must be valid for the lifetime 'a as defined on the block
at 1:44...
  |
1 | fn longest<'a>(x: &str, y: &str) -> &'a str {
  |                                             ^

문제는 resultlongest 함수가 끝나는 지점에서 스코프 밖으로 벗어나게 되어 메모리 해제가 일어나게 되는데, 이 함수로부터 result의 참조자를 반환하려는 시도를 한다는 점입니다. 이 댕글링 참조자를 변경시킬 라이프타임 파라미터를 특정할 방법은 없으며, 러스트는 우리가 댕글링 참조자를 만들게끔 놔두지 않습니다. 이 경우, 가장 좋은 수정 방법은 참조자보다는 차라리 값을 소유한 데이터 타입을 리턴하도록 하여 호출하는 함수가 값을 할당 해제하도록 하는 것입니다.

궁극적으로, 라이프타임 문법은 함수들의 다양한 인자들과 반환 값 사이를 연결하는 것에 대한 것입니다. 이들이 일단 연결되고 나면, 러스트는 메모리에 안전한 연산들을 허용하고 댕글링 포인터를 생성하거나 그렇지 않은 경우 메모리 안전을 위배하게 될 연산들을 배제하기에 충분한 정보를 갖게 됩니다.

구조체 정의 상에서의 라이프타임 명시

현재까지 우리는 소유권 있는 타입만 들고 있는 구조체들만 정의해왔습니다. 구조체가 참조자를 들고 있도록 할 수 있지만, 구조체 정의 내의 모든 참조자들에 대하여 라이프타임을 표시할 필요가 있습니다. Listing 10-24에 스트링 슬라이스를 들고 있는 ImportantExcerpt라고 명명된 구조체가 있습니다:

Filename: src/main.rs

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.')
        .next()
        .expect("Could not find a '.'");
    let i = ImportantExcerpt { part: first_sentence };
}

Listing 10-24: 참조자를 들고 있는 구조체, 따라서 정의 부분에 라이프타임 명시가 필요합니다

이 구조체는 스트링 슬라이스를 담을 수 있는 part라는 하나의 필드를 갖고 있는데, 이것이 참조자입니다. 제네릭 데이터 타입과 마찬가지로, 제네릭 라이프타임 파라미터의 이름을 구조체의 이름 뒤편에 꺾쇠괄호 안에다 선언하여 구조체 정의의 본체 내에서 이 라이프타임 파라미터를 이용할 수 있도록 해야 합니다.

여기 이 main 함수는 변수 novel이 소유하고 있는 String의 첫 문장에 대한 참조자를 들고 있는 ImportantExcerpt 구조체의 인스턴스를 생성합니다.

라이프타임 생략

이 절에서, 우리는 모든 참조자가 라이프타임을 가지고 있으며, 참조자를 사용하는 함수나 구조체에 대하여 라이프타임 파라미터를 특정할 필요가 있다고 배웠습니다. 하지만, Listing 10-25에서 다시 보여주듯이, 4장의 "스트링 슬라이스"절의 함수는 라이프타임 명시 없이도 컴파일이 됐었지요:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}
#}

Listing 10-25: 파라미터와 반환 값의 타입이 참조자임에도 불구하고 라이프타임 명시 없이 컴파일되었던, 4장에서 정의한 바 있는 함수

이 함수가 라이프타임 없이 컴파일되는 이유는 역사가 있습니다: 1.0 이전 시절의 러스트에서는 이 코드가 실제로 컴파일되지 않았습니다. 모든 참조자들은 명시적인 라이프타임이 필요했지요. 그 시절, 함수 시그니처는 아래와 같이 작성되었습니다:

fn first_word<'a>(s: &'a str) -> &'a str {

수많은 러스트 코드를 작성하고 난 후, 러스트 팀은 러스트 프로그래머들이 특정한 상황에서 똑같은 라이프타임 명시를 계속하여 타이핑하고 있다는 사실을 발견하게 되었습니다. 이 상황들은 예측 가능하며 몇 가지 결정론적인 패턴을 따르고 있었습니다. 그리하여 러스트 팀은 러스트 컴파일러 코드 내에 이 패턴들을 프로그래밍하여 이러한 상황 내에서는 프로그래머가 명시적으로 라이프타임 명시를 추가하도록 강제하지 않고 빌림 검사기가 라이프타임을 추론할 수 있도록 하였습니다.

더 많은 결정론적인 패턴들이 출현하여 컴파일러 내에 추가될 가능성이 충분하기에 이러한 러스트의 역사에 대해 언급하였습니다. 나중에는 더욱 적은 라이프타임 명시만이 필요할지도 모르지요.

참조자에 대한 러스트의 분석 기능 내에 프로그래밍된 패턴들을 일컬어 라이프타임 생략 규칙(lifetime elision rules) 이라고 합니다. 이들은 프로그래머가 따라야 하는 규칙들이 아닙니다; 이 규칙들은 컴파일러가 고려할 특정한 경우의 집합이고, 여러분의 코드가 이러한 경우에 들어맞으면, 여러분은 명시적으로 라이프타임을 작성할 필요가 없어집니다.

생략 규칙들은 모든 추론을 제공하지는 않습니다: 만일 러스트가 결정론적으로 이 규칙들을 적용했지만 여전히 참조자들이 어떤 라이프타임을 가지고 있는지에 대하여 모호하다면, 해당하는 남은 참조자들의 라이프타임이 어떻게 되어야 하는지에 대해 추측하지 않을 것입니다. 이러한 경우, 컴파일러는 여러분에게 이 참조자들이 서로 어떻게 연관되는지에 대하여 여러분의 의도에 맞게끔 라이프타임을 추가함으로써 해결 가능한 에러를 표시할 것입니다.

먼저 몇 가지 정의들을 봅시다: 함수나 메소드의 파라미터에 대한 라이프타임을 입력 라이프타임(input lifetime) 이라고 하며, 반환 값에 대한 라이프타임을 출력 라이프타임(output lifetime) 이라고 합니다.

이제 명시적인 라이프타임이 없을 때 참조자가 어떤 라이프타임을 가져야 하는지 알아내기 위해서 컴파일러가 사용하는 규칙들을 봅시다. 첫 번째 규칙은 입력 라이프타임에 적용되고, 다음의 두 규칙들은 출력 라이프타임에 적용됩니다. 만일 컴파일러가 이 세 가지 규칙의 끝에 도달하고 여전히 라이프타임을 알아낼 수 없는 참조자가 있다면, 컴파일러는 에러와 함께 멈출 것입니다.

  1. 참조자인 각각의 파라미터는 고유한 라이프타임 파라미터를 갖습니다. 바꿔 말하면, 하나의 파라미터를 갖는 함수는 하나의 라이프타임 파라미터를 갖고: fn foo<'a>(x: &'a i32), 두 개의 파라미터를 갖는 함수는 두 개의 라이프타임 파라미터를 따로 갖고: fn foo<'a, 'b>(x: &'a i32, y: &'b i32), 이와 같은 식입니다.

  2. 만일 정확히 딱 하나의 라이프타임 파라미터만 있다면, 그 라이프타임이 모든 출력 라이프타임 파라미터들에 대입됩니다: fn foo<'a>(x: &'a i32) -> &'a i32.

  3. 만일 여러 개의 입력 라이프타임 파라미터가 있는데, 메소드라서 그중 하나가 &self 혹은 &mut self라고 한다면, self의 라이프타임이 모든 출력 라이프타임 파라미터에 대입됩니다. 이는 메소드의 작성을 더욱 멋지게 만들어줍니다.

우리가 직접 컴파일러가 된 척하여 Listing 10-25의 first_word 함수의 시그니처에 있는 참조자들의 라이프타임이 무엇인지 알아내기 위해 이 규칙들을 적용해 봅시다. 이 시그니처는 참조자들과 관련된 아무런 라이프타임도 없이 시작합니다:

fn first_word(s: &str) -> &str {

그러면 (컴파일러로서의) 우리는 첫 번째 규칙을 적용하는데, 이는 각각의 파라미터가 고유의 라이프타임을 갖는다고 말해주고 있습니다. 우리는 이를 평범하게 'a라고 명명할 것이며, 따라서 이제 시그니처는 다음과 같습니다:

fn first_word<'a>(s: &'a str) -> &str {

두 번째 규칙 상에 놓이게 되는데, 이는 정확히 단 하나의 입력 라이프타임만 존재하기 때문에 적용됩니다. 두 번째 규칙은 그 하나의 입력 파라미터에 대한 라이프타임이 출력 라이프타임에 대입된다고 말하고 있으므로, 이제 시그니처는 다음과 같아집니다:

fn first_word<'a>(s: &'a str) -> &'a str {

이제 이 함수 시그니처의 모든 참조자들이 라이프타임을 갖게 되었고, 컴파일러는 프로그래머에게 이 함수 시그니처 내의 라이프타임을 명시하도록 요구하지 않고도 분석을 계속할 수 있게 되었습니다.

또 다른 예제를 해보려는데, 이번에는 Listing 10-20에서와 같이 우리가 처음 시작할 때의 아무런 라이프타임 파라미터도 가지고 있지 않은 longest 함수를 가지고 해 봅시다:

fn longest(x: &str, y: &str) -> &str {

다시 한번 우리가 컴파일러가 된 척하여, 첫 번째 규칙을 적용해봅시다: 각각의 파라미터는 고유의 라이프타임을 갖습니다. 이번에는 두 개의 파라미터들이 있으므로, 두 개의 라이프타임을 갖게 됩니다:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

두 번째 규칙을 살펴봤을 때, 하나 이상의 입력 라이프타임이 있으므로 적용되지 않습니다. 세번째 규칙을 살펴봤을 때, 이 또한 적용되지 않는데 이는 이것이 메소드가 아니라 함수이고, 따라서 어떠한 파라미터도 self가 아니기 때문입니다. 따라서 규칙이 더 이상 남아있지 않은데, 우리는 아직 반환 다임의 라이프타임이 무엇인지 알아내지 못했습니다. 이것이 바로 Listing 10-20의 코드를 컴파일하려 시도했을 때 에러가 발생한 이유입니다: 컴파일러는 자신이 알고 있는 라이프타임 생략 규칙들을 통해 작업을 수행했지만, 여전히 이 시그니처의 참조자들에 대한 모든 라이프타임을 알아낼 수 없으니까요.

세번째 규칙이 오직 메소드 시그니처에 대해서만 실제로 적용되므로, 이제 그러한 경우에서의 라이프타임을 살펴보고, 어째서 세번서 규칙이 메소드 시그니처의 라이프타임을 매우 흔하게 생략해도 된다는 것을 의미하는지 알아봅시다.

메소드 정의 내에서의 라이프타임 명시

라이프타임을 가진 구조체에 대한 메소드를 구현할 때, 문법은 또다시 Listing 10-10에서 보신 바와 같이 제네릭 타입 파라미터의 그것과 같습니다: 라이프타임 파라미터가 선언되고 사용되는 곳은 라이프타임 파라미터가 구조체의 필드들 혹은 메소드 인자와 반환 값과 연관이 있는지 없는지에 따라 달린 문제입니다.

구조체 필드를 위한 라이프타임 이름은 언제나 impl 키워드 뒤에 선언되어야 하며, 그러고 나서 구조체의 이름 뒤에 사용되어야 하는데, 이 라이프타임들은 구조체 타입의 일부이기 때문입니다.

impl 블록 안에 있는 메소드 시그니처에서, 참조자들이 구조체 필드에 있는 참조자들의 라이프타임과 묶일 수도 있고, 혹은 서로 독립적일 수도 있습니다. 여기에 더해, 라이프타임 생략 규칙이 종종 적용되어 메소드 시그니처 내에 라이프타임 명시를 할 필요가 없습니다. Listing 10-24에서 정의했던 ImportantExcerpt라는 이름의 구조체를 이용한 몇 가지 예제를 봅시다.

먼저, 여기 level라는 이름의 메소드가 있습니다. 파라미터는 오직 self에 대한 참조자이며, 반환 값은 무언가에 대한 참조자가 아닌, 그냥 i32입니다:


# #![allow(unused_variables)]
#fn main() {
# struct ImportantExcerpt<'a> {
#     part: &'a str,
# }
#
impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}
#}

impl뒤의 라이프타임 파라미터 선언부와 타입 이름 뒤에서 이를 사용하는 것이 필요하지만, 첫 번째 생략 규칙때문에 self로의 참조자의 라이프타임을 명시할 필요는 없습니다.

아래는 세번째 라이프타임 생략 규칙이 적용되는 예제입니다:


# #![allow(unused_variables)]
#fn main() {
# struct ImportantExcerpt<'a> {
#     part: &'a str,
# }
#
impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}
#}

두 개의 입력 라이프타임이 있으므로, 러스트는 첫 번째 라이프타임 생략 규칙을 적용하여 &selfannouncement에게 각각 라이프타임을 부여합니다. 그다음, 파라미터 중 하나가 &self이므로, 반환 타입은 &self의 라이프타임을 얻고, 모든 라이프타임들이 추론되었습니다.

정적 라이프타임(Static lifetime)

우리가 논의할 필요가 있는 특별한 라이프타임이 딱 하나 있습니다: 바로 'static입니다. 'static 라이프타임은 프로그램의 전체 생애주기를 가리킵니다. 모든 스트링 리터럴은 'static 라이프타임을 가지고 있는데, 아래와 같이 명시하는 쪽을 선택할 수 있습니다:


# #![allow(unused_variables)]
#fn main() {
let s: &'static str = "I have a static lifetime.";
#}

이 스트링의 텍스트는 여러분의 프로그램의 바이너리 내에 직접 저장되며 여러분 프로그램의 바이너리는 항상 이용이 가능하지요. 따라서, 모든 스트링 리터럴의 라이프타임은 'static입니다.

여러분은 어쩌면 에러 메시지 도움말에서 'static 라이프타임을 이용하라는 제안을 보셨을지도 모릅니다만, 참조자의 라이프타임으로서 'static으로 특정하기 전에, 여러분이 가지고 있는 참조자가 실제로 여러분 프로그램의 전체 라이프타임 동안 사는 것인지 대해 생각해보세요 (혹은 가능하다면 그렇게 오래 살게끔 하고 싶어 할지라도 말이죠). 대부분의 경우, 코드 내의 문제는 댕글링 참조자를 만드는 시도 혹은 사용 가능한 라이프타임들의 불일치이며, 해결책은 이 문제들을 해결하는 것이지 'static 라이프타임으로 특정하는 것이 아닙니다.

제네릭 타입 파라미터, 트레잇 바운드, 라이프타임을 함께 써보기

그럼 제네릭 타입 파라미터, 트레잇 바운드, 그리고 라이프타임이 하나의 함수에 모두 특정된 문법을 간단하게 살펴봅시다!


# #![allow(unused_variables)]
#fn main() {
use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
    where T: Display
{
    println!("Announcement! {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
#}

이것은 Listing 10-21에 나온 바 있는 두 스트링 슬라이스 중 긴 쪽을 반환하는 longest 함수지만, ann이라는 이름의 추가 인자를 가지고 있습니다. ann의 타입은 제네릭 타입 T인데, where 절을 가지고 특정한 바와 같이 Display 트레잇을 구현한 어떤 타입으로도 채워질 수 있습니다. 이 추가 인자는 함수가 스트링 슬라이스들의 길이를 비교하기 전 출력될 것인데, 이것이 Display 트레잇 바운드가 필요한 이유지요. 라이프타임이 제네릭의 한 종류이므로, 라이프타임 파라미터 'a와 제네릭 타입 파라미터 T 둘 모두에 대한 선언이 함수 이름 뒤 꺾쇠괄호 내에 나열되어 있습니다.

정리

이번 절에서 참 많은 것을 다루었습니다! 이제 여러분은 제네릭 타입 파라미터, 트레잇과 트레잇 바운드, 그리고 제네릭 라이프타임 파라미터에 대해 알게되었으니, 여러분은 중복되지 않지만 많은 서로 다른 상활들에서 사용 가능한 코드를 작성할 준비가 되었습니다. 제네릭 타입 파라미터는 코드가 서로 다른 타입에 대해서 적용될 수 있음을 의미합니다. 트레잇과 트레잇 바운드는 그 타입이 제네릭일지라도 해당 타입들이 코드에 필요한 동작을 할 수 있음을 보장합니다. 라이프타임 명시에 의해 특정된 참조자들의 라이프타임 간의 관계는 이 유연한 코드가 어떠한 댕글링 참조자도 만들지 않을 것임을 확신시켜줍니다. 그리고 이 모든 것들이 컴파일 타임에 이루어지므로 런타임 성능에는 영향을 주지 않지요!

믿을진 모르겠지만, 이 부분에 대해 배울 것이 심지어 더 있습니다: 17장에서는 트레잇 객체(trait object)에 대해 다룰 예정인데, 이는 트레잇을 사용하는 또 다른 방법입니다. 19장에서는 라이프타임 명시를 포함하는 더 복잡한 시나리오를 다룰 것입니다. 20장에서는 더 고급 수준의 타입 시스템 특성을 다룰 것입니다. 하지만, 다음 절에서는 러스트에서 어떻게 테스트를 작성하여 우리의 코드가 우리가 원했던 방식대로 모든 기능들을 작동시킨다는 것을 확신할 수 있도록 하는 방법에 대해 이야기해봅시다!

자동화된 테스트 작성하기

프로그램 테스팅은 버그의 존재를 보여주는 매우 효율적인 방법일 수 있지만, 버그의 부재를 보여주기에는 절망적으로 불충분하다.

에츠허르 W. 데이크스트라(Edsger W. Dijkstra), "겸손한 프로그래머(The Humble Programmer)" (1972) 에츠허르 W. 데이크스트라(Edsger W. Dijkstra)는 그의 1972년 에세이 “겸손한 프로그램 (The Humble Programmer)”에서 “프로그램 테스팅은 버그의 존재를 보여주는 매우 효율적인 방법일 수 있지만, 버그의 부재를 보여주기에는 절망적으로 불충분하다”라고 말했습니다. 이는 우리가 할 수 있는 한 많은 테스트를 시도하지 않아도 된다는 의미가 아닙니다! 우리 프로그램이 정확하다는 것은 즉 우리가 의도한 바를 그대로 우리가 작성한 코드가 수행한다는 뜻입니다. 러스트는 정확성에 매우 많이 신경 쓴 프로그래밍 언어이지만, 정확성이란 복잡한 주제이며 증명하기 쉽지 않습니다. 러스트의 타입 시스템은 이 짐의 큰 부분을 짊어지고 있지만, 타입 시스템이 모든 종류의 부정확성을 잡아낼 수는 없습니다. 러스트에는 보통 말하는 그런 자동화된 소프트웨어 테스트를 작성하기 위한 지원이 언어 내부에 포함되어 있습니다.

예를 들어 어떤 숫자든 입력되면 2를 더하는 add_two라는 함수를 작성한다 칩시다. 이 함수의 시그니처는 정수를 파라미터로 받아들여서 정수를 결과로 반환합니다. 이 함수를 구현하여 컴파일할 때, 러스트는 우리가 이제껏 봐온 모든 종류의 타입 검사 및 빌림 검사를 할 것입니다. 이러한 검사는, 이를테면 String 값이나 유효하지 않은 참조자를 이 함수로 넘기지 않음을 보장해 줄 것입니다. 그러나 러스트는 우리가 정확히 의도한 것을 이 함수가 수행하는가에 대해서는 검사할 수 없는데, 말하자면 파라미터 더하기 10 혹은 파라미터 빼기 50이 아니라 파라미터 더하기 2여야 합니다! 이러한 지점이 바로 테스트가 필요해지는 부분입니다.

예를 들면 우리가 3add_two 함수에 넘겼을 때, 반환 값은 5임을 단언하는(assert) 테스트를 작성할 수 있습니다. 우리는 어떤 종류의 코드 변경이라도 있을 때마다 기존의 정확히 동작하던 부분에 어떠한 변화도 없음을 확신할 수 있도록 이 테스트들을 실행할 수 있습니다.

테스팅은 복잡한 기술입니다: 하나의 장 내에서 어떻게 좋은 테스트를 작성하는지에 대한 모든 상세한 부분을 다룰 수는 없을지라도, 러스트의 테스팅 설비의 역학을 논의할 것입니다. 우리는 여러분이 테스트를 작성할 때 이용 가능한 어노테이션(annotation)과 매크로, 여러분의 테스트를 실행하기 위해 제공되는 기본 동작 및 옵션, 그리고 테스트들을 유닛(unit) 테스트와 통합(integration) 테스트로 조직화하는 방법에 대해 이야기할 것입니다.

테스트를 작성하는 방법

테스트는 테스트 아닌 코드가 프로그램 내에서 기대했던 대로 기능을 하는지 검증하는 러스트 함수입니다. 테스트 함수의 본체는 통상적으로 다음의 세 가지 동작을 수행합니다:

  1. 필요한 데이터 혹은 상태를 설정하기
  2. 우리가 테스트하고 싶은 코드를 실행하기
  3. 그 결과가 우리 예상대로인지 단언하기(assert)

이러한 동작을 하는 테스트 작성을 위해 러스트가 특별히 제공하는 기능들을 살펴봅시다. test 속성, 몇 가지 매크로, 그리고 should_panic 속성들을 포함해서 말이죠.

테스트 함수의 해부

가장 단순하게 말하면, 러스트 내의 테스트란 test 속성(attribute)이 주석으로 달려진 (annotated) 함수입니다. 속성은 러스트 코드 조각에 대한 메타데이터입니다: 한 가지 예로 5장에서 우리가 구조체와 함께 사용했던 derive 속성이 있습니다. 함수를 테스트 함수로 변경하기 위해서는, fn 전 라인에 #[test]를 추가합니다. cargo test 커맨드를 사용하여 테스트를 실행시키면, 러스트는 test 속성이 달려있는 함수들을 실행하고 각 테스트 함수가 성공 혹은 실패했는지를 보고하는 테스트 실행용 바이너리를 빌드할 것입니다.

7장에서 여러분이 카고를 통해 새로운 라이브러리 프로젝트를 만들었을 때, 테스트 함수를 갖고 있는 테스트 모듈이 자동으로 생성되는 것을 보았습니다. 이 모듈은 우리의 테스트를 작성하기 시작하도록 도움을 주는데, 즉 우리가 새로운 프로젝트를 시작할 때마다 매번 테스트 함수를 위한 추가적인 구조 및 문법을 찾아보지 않아도 되게 해 줍니다. 우리는 원하는 만큼 추가적인 테스트 함수들과 테스트 모듈들을 추가할 수 있습니다!

우리는 실제 코드를 테스팅하지는 않으면서 자동으로 만들어진 템플릿 테스트를 가지고 실험하는 식으로 테스트가 어떻게 동작하는지를 몇 가지 관점에서 탐구할 것입니다. 그러고 나서 우리가 작성한 몇몇 코드를 호출하고 동작이 정확한지를 확고히 하는 실제의 테스트를 작성해 볼 것입니다.

adder라고 하는 새로운 라이브러리 프로젝트를 만듭시다:

$ cargo new adder
     Created library `adder` project
$ cd adder

여러분의 adder 라이브러리 내에 있는 src/lib.rs 파일의 내용물은 Listing 11-1과 같아야 합니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}
#}

Listing 11-1: cargo new를 이용하여 자동으로 생성된 테스트 모듈과 함수

지금은 제일 위의 두 줄은 무시하고 함수가 어떻게 작동하는지 알아보는데 집중합시다. fn 라인 전의 #[test] 어노테이션을 주목하세요: 이 속성이 바로 이것이 테스트 함수임을 나타내므로, 테스트 실행기는 이 함수를 테스트로 다루어야 한다는 것을 알게 됩니다. 또한 우리는 tests 모듈 내에 일반적인 시나리오를 셋업 하거나 일반적인 연산을 수행하는 것을 돕기 위한 테스트 아닌 함수를 넣을 수 있으므로, 어떤 함수가 테스트 함수인지 #[test]를 이용하여 나타낼 필요가 있습니다.

이 함수의 본체는 2 + 2가 4와 같음을 단언하기 위해 assert_eq! 매크로를 사용합니다. 이 단언은 통상적인 테스트에 대한 형식 예제로서 제공됩니다. 실행하여 이 테스트가 통과되는지 확인해봅시다.

cargo test 커맨드는 Listing 11-2에서 보는 바와 같이 우리 프로젝트에 있는 모든 테스트를 실행합니다:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.22 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Listing 11-2: 자동으로 생성된 테스트를 실행한 결과

카고는 테스트를 컴파일하고 실행했습니다. Compiling, Finished, 그리고 Running 라인 이후에는 running 1 test 라인이 있습니다. 그다음 라인에는 생성된 테스트 함수의 이름인 it_works가 나타나고, 테스트의 실행 결과 ok가 나타납니다. 그러고 나서 테스트 실행의 전체 요약이 나타납니다. test result: ok.는 모든 테스트가 통과했다는 뜻입니다. 1 passed; 0 failed는 통과하거나 실패한 테스트의 개수를 추가적으로 보여줍니다.

우리가 무시하라고 표시한 테스트가 없기 때문에, 요약문에 0 ignored라고 표시됩니다. 다음 절인 "테스트의 실행방식 제어하기"에서 테스트를 무시하는 것에 대해 다룰 것입니다.

0 measured 통계는 성능을 측정하는 벤치마크 테스트를 위한 것입니다. 벤치마크 테스트는 이 글이 쓰인 시점에서는 오직 나이틀리(nightly) 러스트에서만 사용 가능합니다. 나이틀리 러스트에 대한 더 많은 정보는 1장을 보세요.

Doc-tests adder로 시작하는 테스트 출력의 다음 부분은 문서 테스트의 결과를 보여주기 위한 것입니다. 아직 어떠한 문서 테스트도 없긴 하지만, 러스트는 우리의 API 문서 내에 나타난 어떠한 코드 예제라도 컴파일할 수 있습니다. 이 기능은 우리의 문서와 코드가 동기화를 유지하도록 돕습니다! 우리는 14장의 "문서 주석"절에서 문서 테스트를 작성하는 방법에 대해 이야기할 것입니다. 지금은 Doc-tests 출력을 무시할 것입니다.

우리의 테스트의 이름을 변경하고 테스트 출력이 어떻게 변하는지를 살펴봅시다. 다음과 같이 it_works 함수의 이름을 exploration으로 변경하세요:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }
}
#}

그러고 나서 cargo test를 다시 실행시킵니다. 이제 출력 부분에서 it_works 대신 exploration을 볼 수 있을 것입니다:

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

다른 테스트를 추가해봅시다. 하지만 이번에는 실패하는 테스트를 만들 것입니다! 테스트 함수 내의 무언가가 패닉을 일으키면 테스트는 실패합니다. 각 테스트는 새로운 스레드 내에서 실행되며, 테스트 스레드가 죽은 것을 메인 스레드가 알게 되면, 테스트는 실패한 것으로 표시됩니다. 9장에서 패닉을 유발하는 가장 단순한 방법에 대해 이야기했었습니다: 바로 panic! 매크로를 호출하는 것이죠! 새로운 테스트를 입력하여 여러분의 src/lib.rs가 Listing 11-3과 같은 모양이 되게 해 보세요:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}
#}

Listing 11-3: panic! 매크로를 호출하기 떄문에 실패하게 될 두번째 테스트 추가

cargo test를 이용하여 다시 한번 테스트를 실행시키세요. 결과 출력은 Listing 11-4와 같이 나올 것인데, 이는 exploration 테스트는 통과하고 another는 실패했음을 보여줍니다:

running 2 tests
test tests::exploration ... ok
test tests::another ... FAILED

failures:

---- tests::another stdout ----
	thread 'tests::another' panicked at 'Make this test fail', src/lib.rs:10:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed

Listing 11-4: 한 테스트는 통과하고 다른 한 테스트는 실패할 때의 테스트 결과

test tests::another 라인은 ok 대신 FAILED을 보여줍니다. 개별 결과 부분과 요약 부분 사이에 새로운 두 개의 섹션이 나타납니다: 첫번째 섹션은 테스트 실패에 대한 구체적인 이유를 표시합니다. 이 경우, anotherpanicked at 'Make this test fail' 때문에 실패했는데, 이는 src/lib.rs의 9번 라인에서 발생했습니다. 다음 섹션은 실패한 모든 테스트의 이름만 목록화한 것인데, 이는 테스트들이 많이 있고 구체적인 테스트 실패 출력이 많을 때 유용합니다. 실패하는 테스트의 이름은 이를 더 쉽게 디버깅하기 위해서 해당 테스트만을 실행시키는데 사용될 수 있습니다; "테스트의 실행방식 제어하기" 절에서 테스트를 실행시키는 방법에 대한 더 많은 내용을 이야기할 것입니다.

요약 라인이 가장 마지막에 표시됩니다: 전체적으로, 우리의 테스트 결과는 FAILED입니다. 우리는 하나의 테스트에 통과했고 하나의 테스트에 실패했습니다.

이제 서로 다른 시나리오에서 테스트 결과가 어떻게 보이는지를 알았으니, panic! 외에 테스트 내에서 유용하게 쓰일 수 있는 몇 가지 매크로를 봅시다.

assert! 매크로를 이용하여 결과 확인하기

표준 라이브러리에서 제공하는 assert! 매크로는 여러분이 테스트이 어떤 조건이 true임을 보장하기를 원하는 경우 유용합니다. assert! 매크로에는 부울린 타입으로 계산되는 인자가 제공됩니다. 만일 값이 true라면 assert!는 아무일도 하지 않고 테스트는 통과됩니다. 만일 값이 false라면, assert!panic! 매크로를 호출하는데, 이것이 테스트를 실패하게 합니다. 이는 우리의 코드가 우리 의도대로 기능하고 있는지를 체크하는 것을 도와주는 매크로 중 하나입니다.

5장에 있는 Listing 5-9에서, Rectangle 구조체와 can_hold 메소드를 다루었는데, 여기 Listing 11-5에 다시 나왔습니다. 이 코드를 src/main.rs 대신 src/lib.rs에 넣고, assert! 매크로를 사용하여 테스트를 작성해봅시다.

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[derive(Debug)]
pub struct Rectangle {
    length: u32,
    width: u32,
}

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

Listing 11-5: 5장의 Rectangle 구조체와 can_hold 메소드 이용하기

can_hold 메소드는 부울린 값을 반환하는데, 이는 assert! 매크로를 위한 완벽한 사용 사례라는 의미입니다! Listing 11-6에서는 길이 8에 너비 7인 Rectangle 인스턴스를 만들고, 이것이 길이 5에 너비 1인 다른 Rectangle 인스턴스를 포함할 수 있는지 단언(assert)해보는 것으로 can_hold 메소드를 시험하는 테스트를 작성합니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle { length: 8, width: 7 };
        let smaller = Rectangle { length: 5, width: 1 };

        assert!(larger.can_hold(&smaller));
    }
}
#}

Listing 11-6: 큰 사각형이 작은 사각형을 정말로 담을 수 있는지 검사하는 can_hold를 위한 테스트

tests 모듈 내에 새로운 라인이 추가된 것을 주목하세요: use super::*;. tests 모듈은 우리가 7장에서 다루었던 보통의 가시성 규칙을 따르는 일반적인 모듈입니다. 우리가 내부 모듈 내에 있기 때문에, 외부 모듈에 있는 코드를 내부 모듈의 스코프로 가져올 필요가 있습니다. 여기서는 글롭(*)을 사용하기로 선택했고 따라서 우리가 외부 모듈에 정의한 어떠한 것이듯 이 tests모듈에서 사용 가능합니다.

우리의 테스트는 larger_can_hold_smaller로 명명되었고, 요구된 바와 같이 Rectangle 인스턴스를 두 개 생성했습니다. 그 뒤 assert! 매크로를 호출하고 larger.can_hold(&smaller) 호출의 결과값을 인자로서 넘겼습니다. 이 표현식은 true를 반환할 예정이므로, 우리의 테스트는 통과해야 합니다. 자, 이제 알아봅시다!

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

통과되었군요! 이번에는 작은 사각형이 큰 사각형을 포함시킬수 없음을 단언하는 또 다른 테스트를 추가합시다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle { length: 8, width: 7 };
        let smaller = Rectangle { length: 5, width: 1 };

        assert!(!smaller.can_hold(&larger));
    }
}
#}

이 경우 can_hold 함수의 올바른 결과값은 false이므로, assert! 매크로에게 넘기기 전에 이 결과를 반대로 만들 필요가 있습니다. 결과적으로, 우리의 테스트는 can_holdfalse를 반환할 경우에만 통과할 것입니다:

running 2 tests
test tests::smaller_cannot_hold_larger ... ok
test tests::larger_can_hold_smaller ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

통과하는 테스트가 두 개가 되었습니다! 이제는 만약 우리의 코드에 버그가 있을 때는 테스트 결과가 어찌되는지 봅시다. can_hold 메소드의 구현 부분 중 큰(>) 부등호를 이용해 길이를 비교하는 부분을 작은(<) 부등호로 바꿔봅시다:


# #![allow(unused_variables)]
#fn main() {
# #[derive(Debug)]
# pub struct Rectangle {
#     length: u32,
#     width: u32,
# }
// --snip--

impl Rectangle {
    pub fn can_hold(&self, other: &Rectangle) -> bool {
        self.length < other.length && self.width > other.width
    }
}
#}

테스트를 실행시키면 이제 아래와 같이 출력됩니다:

running 2 tests
test tests::smaller_cannot_hold_larger ... ok
test tests::larger_can_hold_smaller ... FAILED

failures:

---- tests::larger_can_hold_smaller stdout ----
	thread 'tests::larger_can_hold_smaller' panicked at 'assertion failed:
    larger.can_hold(&smaller)', src/lib.rs:22:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

우리의 테스트가 버그를 찾았습니다! larger.length는 8이고 smaller.length는 5이므로, can_hold의 길이 부분에 대한 비교값은 이제 false를 반환합니다: 8이 5보다 작지 않으니까요.

aseert_eq!assert_ne!를 이용한 동치(equality) 테스트

기능성을 테스트하는 일반적인 방법은 테스트 내의 코드의 결과값과 우리가 기대하는 값을 비교하여 둘이 서로 같은지를 확실히 하는 것입니다. 이를 assert! 매크로에 ==를 이용한 표현식을 넘기는 식으로 할 수도 있습니다. 그러나 이러한 테스트를 더 편리하게 수행해주는 표준 라이브러리가 제공하는 한 쌍의 매크로 - assert_eq!assert_ne! - 가 있습니다. 이 매크로들은 각각 동치(equality)와 부동(inequality)을 위해 두 인자를 비교합니다. 또한 이들은 만일 단언에 실패한다면 두 값을 출력해 주는데, 이는 테스트가 실패했는지를 포기 더 쉬워집니다; 반면, assert!== 표현식에 대해 false 값을 얻었음을 가리킬 뿐, 어떤 값이 false값을 야기했는지는 알려주지 않습니다.

Listing 11-7와 같이, 파라미터에 2를 더하여 결과를 반환하는 add_two 함수를 작성합시다. 그 후 assert_eq! 매크로를 이용하여 이 함수를 테스트하겠습니다.

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
pub fn add_two(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        assert_eq!(4, add_two(2));
    }
}
#}

Listing 11-7: assert_eq! 매크로를 이용하는 add_two 함수 테스트

이게 통과하는지 확인해 봅시다!

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

assert_eq! 매크로에 제공한 첫번째 인자 4는 add_two(2) 호출의 결과와 동일합니다. 이 테스트에 대한 라인은 test tests::it_adds_two ... ok이고, ok 문자열은 테스트가 통과했음을 나타냅니다!

assert_eq!를 이용하는 테스트가 실패했을때는 어떻게 보이는지를 알아보기 위해 테스트에 버그를 집어넣어 봅시다. add_two 함수에 3을 대신 더하는 형태로 구현을 변경해 보세요:


# #![allow(unused_variables)]
#fn main() {
pub fn add_two(a: i32) -> i32 {
    a + 3
}
#}

테스트를 다시 실행해 보세요:

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----
        thread 'tests::it_adds_two' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`', src/lib.rs:11:8

failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

우리의 테스트가 버그를 잡았어요! it_adds_two 테스트는 assertion failed: `(left == right)`라는 메세지와 left는 4였고 right는 5였다는 것으로 보여줌과 함께 실패했습니다. 이 메세지는 우리가 디버깅을 시작하는데 유용한 도움을 줍니다: assert_eq!left 인자는 4였는데, add_two(2)를 넣은 right` 인자는 5라고 말해주고 있습니다.

몇몇 언어와 테스트 프레임워크 내에서는, 두 값이 같은지를 단언하는 함수의 파라미터를 expectedactual로 부르며, 우리가 인자를 넣는 순서가 중요하다는 점을 기억하세요. 하지만 러스트에서는 그 대신 leftright라고 불리며 우리가 기대한 값과 테스트 내의 코드가 생성하는 값을 지정하는 순서는 중요치 않습니다. 이 테스트의 단언을 assert_eq!(add_two(2), 4)로 작성할 수도 있는데, 이는 assertion failed: `(left == right)`left5right4라는 실패 메세지를 만들어낼 것입니다.

assert_ne! 매크로는 우리가 제공한 두 개의 값이 서로 갖지 않으면 통과하고 동일하면 실패할 것입니다. 이 매크로는 어떤 값이 될 것인지는 정확히 확신하지 못하지만, 어떤 값이라면 절대로 될 수 없는지는 알고 있을 경우에 가장 유용합니다. 예를 들면, 만일 어떤 함수가 입력값을 어떤 방식으로든 변경한다는 것을 보장하지만, 그 입력값이 우리가 테스트를 실행한 요일에 따라 달라지는 형태라면, 단언을 하는 가장 좋은 방법은 함수의 결괏값이 입력값과 같지 않다는 것일지도 모릅니다.

표면 아래에서, assert_eq!assert_ne! 매크로는 각각 ==!= 연산자를 이용합니다. 단언에 실패하면, 이 매크로들은 디버그 포맷팅을 사용하여 인자들을 출력하는데, 이는 비교되는 값들이 PartialEqDebug 트레잇을 구현해야 한다는 의미입니다. 모든 기본 타입과 표준 라이브러리가 제공하는 대부분의 타입들은 이 트레잇들을 구현하고 있습니다. 여러분이 정의한 구조체나 열거형에 대해서, 해당 타입의 값이 서로 같은지 혹은 다른지를 단언하기 위해서는 PartialEq를 구현할 필요가 있습니다. 단언에 실패할 경우에 값을 출력하기 위해서는 Debug를 구현해야 합니다. 5장에서 설명한 바와 같이 두 트레잇 모두 추론 가능한(derivable) 트레잇이기 때문에, 이 트레잇의 구현은 보통 #[derive(PartialEq, Debug)] 어노테이션을 여러분의 구조체나 열거형 정의부에 추가하는 정도로 간단합니다. 이에 대한 것과 다른 추론 가능한 트레잇에 대한 더 자세한 내용은 부록 C를 참고하세요.

커스텀 실패 메세지 추가하기

또한 우리는 assert!, assert_eq!assert_ne! 매크로의 추가 인자로서 커스텀 메세지를 입력하여 실패 메세지와 함께 출력되도록 할 수 있습니다. assert!가 요구하는 하나의 인자 후에 지정된 인자들이나 assert_eq!assert_ne!가 요구하는 두 개의 인자 후에 지정된 인자들은 우리가 8장의 “+ 연산자나 format! 매크로를 이용한 접합”절에서 다루었던 format! 매크로에 넘겨지므로, 여러분은 {} 변경자 (placeholder)를 갖는 포맷 스트링과 이 변경자에 입력될 값들을 넘길 수 있습니다. 커스텀 메세지는 해당 단언의 의미를 문서화하기 위한 용도로서 유용하므로, 테스트가 실패했을 때, 코드에 어떤 문제가 있는지에 대해 더 좋은 생각을 가질 수 있습니다.

예를 들어, 이름을 부르며 사람들을 환영하는 함수가 있고, 이 함수에 넘겨주는 이름이 출력 내에 있는지 테스트하고 싶다고 칩시다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
pub fn greeting(name: &str) -> String {
    format!("Hello {}!", name)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}
#}

여기서 이 프로그램의 요구사항은 아직 합의되지 않았고, 인사말의 시작 지점에 있는 Hello 텍스트가 변경될 것이라는 점이 꽤나 확실한 상태라고 칩시다. 우리는 그런 변경사항이 생기더라도 이름에 대한 테스트를 갱신할 필요는 없다고 결정했고, 따라서 greeting 함수로부터 반환된 값과 정확히 일치하는 체크 대신, 출력 값이 입력 파라미터의 텍스트를 포함하고 있는지만 단언할 것입니다.

greetingname을 포함하지 않도록 변경하는 것으로 버그를 집어넣어 테스트 실패가 어떻게 보이는지 살펴봅시다:


# #![allow(unused_variables)]
#fn main() {
pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}
#}

이 테스트를 수행하면 다음을 출력합니다:

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
    thread 'tests::greeting_contains_name' panicked at 'assertion failed:
    result.contains("Carol")', src/lib.rs:12:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::greeting_contains_name

이 결과는 그저 단언이 실패했으며 몇 번째 줄의 단언이 실패했는지만을 나타냅니다. 이 경우에서 더 유용한 실패 메세지는 greeting 함수로부터 얻은 값을 출력하는 것일 테지요. 테스트 함수를 바꿔서 greeting 함수로부터 얻은 실제 값으로 채워질 변경자를 이용한 포맷 스트링으로부터 만들어지는 커스텀 실패 메세지를 줄 수 있도록 해봅시다:

#[test]
fn greeting_contains_name() {
    let result = greeting("Carol");
    assert!(
        result.contains("Carol"),
        "Greeting did not contain name, value was `{}`", result
    );
}

이제 테스트를 다시 실행시키면, 더 많은 정보를 가진 에러 메세지를 얻을 것입니다:

---- tests::greeting_contains_name stdout ----
	thread 'tests::greeting_contains_name' panicked at 'Greeting did not contain
    name, value was `Hello!`', src/lib.rs:12:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.

이제 실제로 테스트 출력에서 얻어진 값을 볼 수 있고, 이는 우리가 기대했던 일 대신 실제 어떤 일이 일어났는지 디버깅하는데 도움을 줄 것입니다.

should_panic을 이용한 패닉에 대한 체크

우리의 코드가 우리가 기대한 정확한 값을 반환하는 것을 체크하는 것에 더하여, 우리의 코드가 우리가 기대한 대로 에러가 나는 경우를 처리할 수 있는지 체크하는 것 또한 중요합니다. 예를 들어, 9장의 Listing 9-9에서 우리가 만들었던 Guess 타입을 떠올려보세요. Guess를 이용하는 다른 코드는 Guess 인스턴스가 1과 100 사이의 값만 가질 것이라는 보장에 의존적입니다. 우리는 범위 밖의 값으로 Guess 인스턴스를 만드는 시도가 패닉을 일으킨다는 것을 확실히 하는 테스트를 작성할 수 있습니다.

이는 또 다른 속성인 should_panic를 테스트 함수에 추가함으로써 할 수 있습니다. 이 속성은 함수 내의 코드가 패닉을 일으키면 테스트가 통과하도록 만들어줍니다; 함수 내의 코드가 패닉을 일으키지 않는다면 테스트는 실패할 것입니다.

Listing 11-8은 Guess::new의 에러 조건이 우리 예상대로 발동되는지를 검사하는 테스트를 보여줍니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
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
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}
#}

Listing 11-8: 어떤 조건이 panic!을 일으키는지에 대한 테스트

#[should_panic] 속성이 #[test] 속성 뒤, 그리고 적용될 테스트 함수 앞에 붙었습니다. 이 테스트가 통과될 때의 결과를 봅시다:

running 1 test
test tests::greater_than_100 ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

좋아 보이는군요! 이제 new 함수가 100 이상의 값일 때 패닉을 발생시키는 조건을 제거함으로써 코드에 버그를 넣어봅시다:


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

        Guess {
            value
        }
    }
}
#}

Listing 11-8의 테스트를 실행시키면, 아래와 같이 실패할 것입니다:

running 1 test
test tests::greater_than_100 ... FAILED

failures:

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

이 경우에는 그다지 쓸모 있는 메세지를 얻지 못하지만, 한번 테스트 함수를 살펴보게 되면, 함수가 #[should_panic]으로 어노테이션 되었다는 것을 볼 수 있습니다. 우리가 얻은 실패는 함수 내의 코드가 패닉을 일으키지 않았다는 의미가 됩니다.

should_panic 테스트는 애매할 수 있는데, 그 이유는 이 속성이 단지 코드에서 어떤 패닉이 유발되었음만을 알려줄 뿐이기 때문입니다. should_panic 테스트는 일어날 것으로 예상한 것 외의 다른 이유로 인한 패닉이 일어날 지라도 통과할 것입니다. should_panic 테스트를 더 엄밀하게 만들기 위해서, should_panic 속성에 expected 파라미터를 추가할 수 있습니다. 이 테스트 도구는 실패 메세지가 제공된 텍스트를 담고 있는지 확실히 할 것입니다. 예를 들면, Listing 11-9와 같이 입력된 값이 너무 작거나 혹은 너무 클 경우에 대해 서로 다른 메세지를 가진 패닉을 일으키는 new 함수를 갖고 있는 수정된 Guess 코드를 고려해봅시다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
# pub struct Guess {
#     value: u32,
# }
# 
// --snip

impl Guess {
    pub fn new(value: u32) -> Guess {
        if value < 1 {
            panic!("Guess value must be greater than or equal to 1, got {}.",
                   value);
        } else if value > 100 {
            panic!("Guess value must be less than or equal to 100, got {}.",
                   value);
        }

        Guess {
            value
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "Guess value must be less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}
#}

Listing 11-9: 어떤 조건이 특정 패닉 메세지를 가진 panic!을 일으키는 테스트

이 테스트는 통과할 것인데, 그 이유는 should_panic 속성에 추가한 expected 파라미터 값이 Guess::new 함수가 패닉을 일으킬 때의 메세지의 서브 스트링이기 때문입니다. 우리가 예상하는 전체 패닉 메세지로 특정할 수도 있는데, 그러한 경우에는 Guess value must be less than or equal to 100, got 200.이 되겠지요. 여러분이 should_panic에 대한 기대하는 파라미터를 특정하는 것은 패닉 메세지가 얼마나 유일한지 혹은 유동적인지, 그리고 여러분의 테스트가 얼마나 정확하기를 원하는지에 따라서 달라집니다. 위의 경우, 패닉 메세지의 서브 스트링은 실행된 함수의 코드가 else if value > 100 경우에 해당함을 확신하기에 충분합니다.

expect 메세지를 가진 should_panic 테스트가 실패하면 어떻게 되는지 보기 위해서, 다시 한번 if value < 1 아래 코드 블록과 else if value > 100 아래 코드 블록을 바꿔서 버그를 만들어봅시다:

if value < 1 {
    panic!("Guess value must be less than or equal to 100, got {}.", value);
} else if value > 100 {
    panic!("Guess value must be greater than or equal to 1, got {}.", value);
}

이번에는 should_panic 테스트를 실행하면, 아래와 같이 실패합니다:

running 1 test
test tests::greater_than_100 ... FAILED

failures:

---- tests::greater_than_100 stdout ----
        thread 'tests::greater_than_100' panicked at 'Guess value must be greater than or equal to 1, got 200.', src/lib.rs:11:12
note: Run with `RUST_BACKTRACE=1` for a backtrace.
note: Panic did not include expected string 'Guess value must be less than or
equal to 100'

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

실패 메세지는 이 테스트가 우리 예상에 맞게 실제로 패닉에 빠지기는 했으나, 패닉 메세지가 예상하는 스트링을 포함하지 않고 있다고 말하고 있습니다 (did not include expected string 'Guess value must be less than or equal to 100'.) 우리가 얻어낸 패닉 메세지를 볼 수 이는데, 이 경우에는 Guess value must be greater than or equal to 1, got 200. 이었습니다. 그러면 우리는 어디에 우리의 버그가 있는지를 찾아내기 시작할 수 있습니다!

이제까지 테스트를 작성하는 몇 가지 방법을 알게 되었으니, 우리의 테스트를 실행할 때 어떤 일이 벌어지는지를 살펴보고 cargo test와 함께 사용할 수 있는 어려가지 옵션들에 대해서 탐구해봅시다.

테스트의 실행 방식 제어하기

cargo run이 여러분의 코드를 컴파일하고 난 뒤 그 결과인 바이너리를 실행하는 것과 마찬가지로, cargo test는 여러분의 코드를 테스트 모드에서 컴파일하고 결과로 발생한 테스트 바이너리를 실행합니다. 여러분은 커맨드 라인 옵션을 지정하여 cargo test의 기본 동작을 변경할 수 있습니다. 예를 들어, cargo test를 통해 생성된 바이너리의 기본 동작은 모든 테스트를 병렬적으로 수행하고 테스트가 실행되는 동안 생성된 결과를 캡처하는 것으로, 테스트 결과와 연관된 출력을 읽기 쉽도록 화면에 표시되는 것을 막아버립니다.

어떤 커맨드 라인 옵션은 cargo test에 입력되고 어떤 옵션은 결과 테스트 바이너리에 입력됩니다. 이 두 가지 타입의 인자를 구분하기 위해서, cargo test에 주어질 인자를 먼저 나열하고, 그다음 구분자(separator)로 --를 넣고, 그 뒤 테스트 바이너리에 입력될 인자를 나열합니다. cargo test --help를 실행하는 것은 cargo test에서 사용할 수 있는 옵션을 표시하고, cargo test -- --help를 실행하는 것은 구분자 -- 이후에 나올 수 있는 옵션을 표시합니다.

테스트를 병렬 혹은 연속으로 실행하기

여러 개의 테스트를 실행할 때는, 기본적으로 스레드를 이용하여 병렬적으로 수행됩니다. 이는 테스트가 더 빠르게 실행되어 끝낼 수 있다는 의미이므로, 우리의 코드가 잘 동작하는지 혹은 그렇지 않은지에 대한 피드백을 더 빨리 얻을 수 있습니다. 테스트가 동시에 실행되므로, 여러분의 테스트가 서로 다른 테스트 혹은 공유 상태 값에 의존하지 않는지 주의해야 하는데, 이는 이를테면 현재 작업 디렉토리나 환경 변수와 같은 공유 환경 값을 포함합니다.

예를 들면, 여러분이 작성한 테스트 각각이 test-output.txt라는 파일을 디스크에 만들고 이 파일에 어떤 데이터를 쓰는 코드를 실행한다고 가정해봅시다. 그런 다음 각 테스트는 그 파일로부터 데이터를 읽고, 이 파일이 특정한 값을 담고 있는지 단언하는데, 이 값들은 테스트마다 다릅니다. 모든 테스트들이 동시에 실행되기 때문에, 어떤 테스트가 파일을 쓰고 읽는 동안 다른 테스트가 파일을 덮어쓸지도 모릅니다. 두 번째 테스트는 실패할 것인데, 이는 코드가 정확히 않아서가 아니라 테스트들이 병렬적으로 실행하는 동안 서로에게 간섭을 일으켰기 때문입니다. 한 가지 해결책은 각 테스트가 서로 다른 파일을 쓰도록 확실히 하는 것일 겁니다; 또 다른 해결책은 테스트를 한 번에 하나씩만 실행하는 것입니다.

만일 여러분이 테스트들을 병렬적으로 실행하고 싶지 않을 경우, 혹은 여러분이 사용되는 스레드의 개수에 대한 더 정밀한 제어를 하고 싶을 경우, 여러분은 --test-threads 플리그와 테스트 바이너리에서 사용하고 싶은 스레드 개수를 넘길 수 있습니다. 다음 예제를 봅시다:

$ cargo test -- --test-threads=1

여기서는 테스트 스레드의 개수에 1을 지정했는데, 이는 프로그램이 어떠한 병렬 처리도 사용하지 않음을 얘기해줍니다. 테스트를 하나의 스레드에서 실행하는 것은 병렬로 수행하는 것에 비해 시간이 더 오래 걸리겠지만, 테스트들이 어떤 상태를 공유할 경우 서로가 간섭할 가능성이 없어질 것입니다.

함수 결과 보여주기

기본적으로 어떤 테스트가 통과하면, 러스트의 테스트 라이브러리는 표준 출력(standard output)으로 출력되는 어떤 것이든 캡처합니다. 예를 들면, 우리가 테스트 내에서 println!을 호출하고 이 테스트가 통과하면, println! 출력을 터미널에서 볼 수 없습니다: 우리는 오직 그 테스트가 통과되었다고 표시된 라인만 볼 뿐입니다. 만일 테스트가 실패하면, 실패 메세지 아래에 표준 출력으로 출력되었던 어떤 것이든 보게 될 것입니다.

예를 들어, Listing 11-10은 파라미터의 값을 출력한 뒤 10을 반환하는 바보 같은 함수를 보여주고 있습니다. 그리고 통과하는 테스트와 실패하는 테스트를 갖추고 있습니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
fn prints_and_returns_10(a: i32) -> i32 {
    println!("I got the value {}", a);
    10
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn this_test_will_pass() {
        let value = prints_and_returns_10(4);
        assert_eq!(10, value);
    }

    #[test]
    fn this_test_will_fail() {
        let value = prints_and_returns_10(8);
        assert_eq!(5, value);
    }
}
#}

Listing 11-10: println!을 호출하는 함수를 위한 테스트

cargo test를 이용하여 이 테스트를 실행했을 때 보게 될 출력은 다음과 같습니다:

running 2 tests
test tests::this_test_will_pass ... ok
test tests::this_test_will_fail ... FAILED

failures:

---- tests::this_test_will_fail stdout ----
        I got the value 8
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left == right)`
  left: `5`,
 right: `10`', src/lib.rs:19:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

I got the value 4라는 메세지를 어디에서도 볼 수 없는데, 이는 성공하는 테스트가 실행시키는 출력이라는 점을 주목하세요. 이 출력 메세지는 캡처되었습니다. 실패한 테스트로부터 얻어진 출력 메세지인 I got the value 8은 테스트 정리 출력 부분에 나타나는데, 이는 테스트 실패 원인 또한 함께 보여줍니다.

만일 성공하는 테스트에 대한 출력 값 또한 볼 수 있기를 원한다면, --nocapture 플래그를 이용하여 출력 캡처 동작을 비활성화시킬 수 있습니다:

$ cargo test -- --nocapture

Listing 11-10의 테스트를 --nocapture 플래그와 함께 실행시키면 다음과 같이 나옵니다:

running 2 tests
I got the value 4
I got the value 8
test tests::this_test_will_pass ... ok
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left == right)`
  left: `5`,
 right: `10`', src/lib.rs:19:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.
test tests::this_test_will_fail ... FAILED

failures:

failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

테스트에서의 출력과 테스트 결과 출력이 분리된 점을 주목하세요; 이는 우리가 이전 절에서 다룬 내용처럼 테스트가 병렬적으로 수행되기 때문입니다. --test-threads=1 옵션과 --nocapture 기능을 동시에 시도하고 출력이 어떻게 바뀌는지를 확인해 보세요!

이름으로 테스트의 일부분만 실행하기

가끔, 모든 테스트 셋을 실행하는 것은 시간이 오래 걸릴 수 있습니다. 만일 여러분이 특정 영역의 코드에 대해서 작업하고 있다면, 그 코드와 연관된 테스트만 실행시키고 싶어 할 수도 있습니다. 여러분은 cargo test에 여러분이 실행시키고 싶어 하는 테스트(들)의 이름들을 인자로 넘김으로써 어떤 테스트들을 실행시킬지 고를 수 있습니다.

테스트의 일부분만을 실행시키는 법을 보여드리기 위해서, Listing 11-11에서 보시는 바와 같이 add_two 함수를 위한 세 개의 테스트를 만들어서 하나만 골라 실행해보겠습니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
pub fn add_two(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_two_and_two() {
        assert_eq!(4, add_two(2));
    }

    #[test]
    fn add_three_and_two() {
        assert_eq!(5, add_two(3));
    }

    #[test]
    fn one_hundred() {
        assert_eq!(102, add_two(100));
    }
}
#}

Listing 11-11: 여러 이름으로 된 세 가지 테스트

만일 테스트를 어떠한 인자 없이 실행시키면, 전에 본 것과 같이 모든 테스트가 병렬적으로 수행될 것입니다:

running 3 tests
test tests::add_two_and_two ... ok
test tests::add_three_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

단일 테스트 실행하기

단 하나의 테스트만 실행시키기 위해 cargo test에 그 테스트 함수의 이름을 넘길 수 있습니다:

$ cargo test one_hundred
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-06a75b4a1f2515e9

running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out

one_hundred라는 이름의 테스트만 실행되었습니다; 다른 두 개의 테스트는 이 이름에 맞지 않습니다. 테스트 출력은 정리 라인의 끝에 2 filtered out이라고 표시함으로써 이 커맨드로 지정한 것보다 많은 테스트를 가지고 있음을 우리에게 알려줍니다.

이 방법으로는 여러 테스트의 이름들을 특정할 수는 없고, cargo test에 주어진 제일 첫 번째 값만 이용될 것입니다.

여러 개의 테스트를 실행시키기 위한 필터링

우리는 테스트 이름의 일부분을 특정할 수 있고, 해당 값과 일치하는 이름의 테스트가 실행될 것입니다. 예를 들면, 우리의 테스트 이름들 중에서 두 개가 add를 포함하므로, cargo test add라고 실행하여 이 두 개의 테스트를 실행시킬 수 있습니다:

$ cargo test add
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-06a75b4a1f2515e9

running 2 tests
test tests::add_two_and_two ... ok
test tests::add_three_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out

이는 add가 이름에 포함된 모든 테스트를 실행시켰고 one_hundred라는 이름의 테스트를 걸러냈습니다. 또한 테스트가 있는 모듈이 테스트의 이름의 일부가 되어 있으므로, 모듈의 이름으로 필터링하여 그 모듈 내의 모든 테스트를 실행시킬 수 있다는 점도 주목하세요.

특별한 요청이 없는 한 몇몇 테스트들 무시하기

이따금씩 몇몇 특정 테스트들은 실행하는데 너무나 시간이 많이 소모될 수 있어서, 여러분은 cargo test의 실행 시 이 테스트들을 배제하고 싶어 할지도 모릅니다. 여러분이 실행시키고자 하는 모든 테스트들을 인자로서 열거하는 것 대신, 다음과 같이 시간이 많이 걸리는 테스트들에 ignore 속성을 어노테이션하여 이들을 배제시킬 수 있습니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[test]
fn it_works() {
    assert_eq!(2 + 2, 4);
}

#[test]
#[ignore]
fn expensive_test() {
    // code that takes an hour to run
}
#}

배제시키고 싶은 테스트에 대하여 #[test] 다음 줄에 #[ignore]를 추가하였습니다. 이제 우리의 테스트들을 실행시키면, it_works가 실행되는 것은 보이지만, expensive-test는 실행되지 않는 것을 볼 수 있습니다:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.24 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

running 2 tests
test expensive_test ... ignored 
test it_works ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out

expensive_testignored 리스트에 포함되었습니다. 만일 무시된 테스트들만 실행시키고 싶다면, cargo test -- --ignored라고 실행함으로써 이를 요청할 수 있습니다.

$ cargo test -- --ignored
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

running 1 test
test expensive_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out

어떠한 테스트를 실행시킬지를 제어함으로써, 여러분은 cargo test의 결과가 빠르게 나오도록 확실히 할 수 있습니다. ignored 테스트들의 결과를 확인하기에 타당한 시점에 있고 해당 결과를 기다릴 시간을 가지고 있을 때, 여러분은 대신 cargo test -- --ignored를 실행시킬 수 있습니다.

테스트 조직화

이 장의 시작 부분에서 언급했듯이, 테스팅은 복잡한 분야이고, 여러 사람들이 서로 다른 용어와 조직화 방식을 이용합니다. 러스트 커뮤니티에서는 테스트에 대해서 두 개의 주요한 카테고리로 나눠 생각합니다: 단위 테스트(unit test) 그리고 *통합 테스트(integration test)*입니다. 단위 테스트는 작고 하나에 더 집중하며, 한 번에 하나의 모듈만 분리하여 테스트하고, 비공개 인터페이스 (private interface)를 테스트합니다. 통합 테스트는 완전히 여러분의 라이브러리 외부에 있으며, 공개 인터페이스 (public interface)를 이용하고 테스트마다 여러 개의 모듈을 잠재적으로 실험함으로써, 다른 외부의 코드가 하는 방식과 동일한 형태로 여러분의 코드를 이용합니다.

두 종류의 테스트 작성 모두가 여러분의 라이브러리 코드 조각들이 따로따로 혹은 함께 사용되었을 때 여러분이 기대하는 바와 같이 작동하는 지를 확신시키는데 중요합니다.

단위 테스트

단위 테스트의 목적은 각 코드의 단위를 나머지 부분과 분리하여 테스트하는 것인데, 이는 코드가 어디 있고 어느 부분이 기대한 대로 동작하지 않는지를 빠르게 정확히 찾아낼 수 있도록 하기 위함입니다. 단위 테스트는 src 디렉토리 내에 넣는데, 각 파일마다 테스트하는 코드를 담고 있습니다. 관례는 각 파일마다 테스트 함수를 담고 있는 tests라는 이름의 모듈을 만들고, 이 모듈에 cfg(test)라고 어노테이션 하는 것입니다.

테스트 모듈과 #[cfg(test)]

테스트 모듈 상의 #[cfg(test)] 어노테이션은 러스트에게 우리가 cargo build를 실행시킬 때가 아니라 cargo test를 실행시킬 때에만 컴파일하고 실행시키라고 말해줍니다. 이는 우리가 오직 라이브러리만 빌드하고 싶을 때 컴파일 시간을 절약시켜주고, 테스트가 포함되어 있지 않으므로 컴파일 결과물의 크기를 줄여줍니다. 통합 테스트는 다른 디렉토리에 위치하기 때문에, 여기에는 #[cfg(test)] 어노테이션이 필요치 않음을 앞으로 보게 될 것입니다. 하지만, 단위 테스트가 해당 코드와 동일한 파일에 위치하기 때문에, #[cfg(test)]를 사용하여 컴파일 결과물에 이들이 포함되지 않아야 함을 특정합니다.

이 장의 첫 번째 절에서 새로운 adder 프로젝트를 생성했을 때, 카고가 우리를 위하여 아래와 같은 코드를 생성했던 것을 상기하세요:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}
#}

이 코드는 자동으로 생성되는 테스트 모듈입니다. cfg 속성은 환경 설정(configuration) 을 의미하며, 러스트에게 뒤따르는 아이템이 특정한 환경 값에 대해서만 포함되어야 함을 말해줍니다. 위의 경우, 환경 값이 test인데, 테스트를 컴파일하고 실행하기 위해 러스트로부터 제공되는 것입니다. 이 속성을 이용함으로써, 카고는 우리가 능동적으로 cargo test를 이용해서 테스트를 실행시킬 경우에만 우리의 테스트 코드를 컴파일합니다. 이는 이 모듈 내에 있을지도 모를 어떠한 헬퍼 함수들, 추가적으로 #[test]라고 어노테이션 된 함수들을 포함합니다.

비공개 함수 테스트하기

테스팅 커뮤니티 내에서 비공개 함수가 직접적으로 테스트되어야 하는지 혹은 그렇지 않은지에 대한 논쟁이 있었고, 다른 언어들은 비공개 함수를 테스트하는 것이 어렵거나 불가능하게 만들어두었습니다. 여러분이 어떤 테스트 이데올로기를 고수하는지와는 상관없이, 러스트의 비공개 규칙은 여러분이 비공개 함수를 테스트하도록 허용해줍니다. 비공개 함수 internal_adder가 있는 Listing 11-12 내의 코드를 고려해 보시죠:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
pub fn add_two(a: i32) -> i32 {
    internal_adder(a, 2)
}

fn internal_adder(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2));
    }
}
#}

Listing 11-12: 비공개 함수 테스트하기

internal_adder 함수는 pub으로 표시되어 있지 않지만, 테스트가 그저 러스트 코드일 뿐이고 tests 모듈도 그냥 또 다른 모듈이기 때문에, internal_adder를 불러들여 호출하는 것이 그냥 되는 것을 주목하세요. 만약 여러분이 비공개 함수를 테스트해야 한다고 생각하지 않는다면, 러스트에서는 여러분이 그렇게 하도록 강제할 일은 없습니다.

통합 테스트

러스트에서 통합 테스트들은 완전히 여러분의 라이브러리 외부에 있습니다. 이들은 여러분의 라이브러리를 다른 코드들과 동일한 방식으로 이용하는데, 이는 이 외부 테스트들이 오직 여러분의 라이브러리의 공개 API 부분에 속하는 함수들만 호출할 수 있다는 의미입니다. 이들의 목적은 여러분의 라이브러리의 수많은 파트들이 함께 올바르게 동작하는지를 시험하는 것입니다. 그 자체로서는 올바르게 동작하는 코드의 단위들도 통합되었을 때는 문제를 일으킬 수 있으므로, 통합된 코드의 테스트 커버율 또한 중요합니다. 통합 테스트를 만들기 위해서는 tests 디렉토리를 먼저 만들 필요가 있습니다.

tests 디렉토리

프로젝트 디렉토리의 최상위, 그러니까 src 옆에 tests 디렉토리를 만듭니다. 카고는 이 디렉토리 내의 통합 테스트 파일들을 찾을 줄 압니다. 그런 후에는 이 디렉토리에 원하는 만큼 많은 테스트 파일을 만들 수 있으며, 카고는 각각의 파일들을 개별적인 크레이트처럼 컴파일할 것입니다.

한 번 통합 테스트를 만들어봅시다. Listing 11-12의 src/lib.rs 코드를 그대로 유지한 채로, tests 디렉토리를 만들고, tests/integration_test.rs라는 이름의 새 파일을 만든 다음, Listing 11-13의 코드를 집어넣으세요.

Filename: tests/integration_test.rs

extern crate adder;

#[test]
fn it_adds_two() {
    assert_eq!(4, adder::add_two(2));
}

Listing 11-13: adder 크레이트 내의 함수에 대한 통합 테스트

코드의 상단에 extern crate adder를 추가했는데, 이는 단위 테스트에서는 필요 없었지요. 이는 tests 디렉토리 내의 각 테스트가 모두 개별적인 크레이트이라서, 우리의 라이브러리를 각각에 가져올 필요가 있기 때문입니다.

tests/integration_test.rs에는 #[cfg(test)]를 이용한 어노테이션을 해줄 필요가 없습니다. 카고는 test 디렉토리를 특별 취급하여 cargo test를 실행시켰을 때에만 이 디렉토리 내의 파일들을 컴파일합니다. 이제 cargo test 실행을 시도해봅시다:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31 secs
     Running target/debug/deps/adder-abcabcabc

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/integration_test-ce99bcc2479f4607

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

출력에 세 개의 섹션이 생겼습니다: 단위 테스트, 통합 테스트, 그리고 문서 테스트입니다. 단위 테스트를 위한 첫 번째 섹션은 우리가 봐오던 것과 동일합니다: 각각의 단위 테스트마다 한 라인 (Listing 11-12에서 우리가 추가한 intenal이라는 이름의 것이 있었죠), 그다음 단위 테스트들의 정리 라인이 있습니다.

통합 테스트 섹션은 Running target/debug/deps/integration-test-ce99bcc2479f4607 이라고 말하는 라인과 함께 시작합니다 (여러분의 출력 값 끝의 해쉬값은 다를 것입니다). 그다음 이 통합 테스트 안의 각 테스트 함수를 위한 라인이 있고, Doc-tests adder 섹션이 시작되기 직전에 통합 테스트의 결과를 위한 정리 라인이 있습니다.

어떠한 src 파일에 단위 테스트 함수를 더 추가하는 것이 단위 테스트 섹션의 테스트 결과 라인을 더 늘린다는 점을 상기하세요. 통합 테스트 파일에 테스트 함수를 더 추가하는 것은 그 파일의 섹션의 라인을 더 늘릴 것입니다. 각 통합 테스트 파일은 고유의 섹션을 가지고 있으므로, 만일 우리가 tests 디렉토리에 파일을 더 추가하면, 통합 테스트 생선이 더 생길 것입니다.

cargo test의 인자로서 테스트 함수의 이름을 명시하는 식으로 특정 통합 테스트 함수를 실행시키는 것도 여전히 가능합니다. 특정한 통합 테스트 파일 내의 모든 테스트를 실행시키기 위해서는, cargo test에 파일 이름을 뒤에 붙인 --test 인자를 사용하세요:

$ cargo test --test integration_test
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/integration_test-952a27e0126bb565

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

이 커맨드는 tests/integration_test.rs 내의 테스트만 실행합니다.

통합 테스트 내의 서브모듈

더 많은 통합 테스트를 추가하게 되면, 이들을 조직화하기 쉽도록 tests 디렉토리 내에 하나 이상의 파일을 만들고 싶어 할지도 모릅니다; 예를 들면, 여러분은 이들이 테스트하는 기능별로 테스트 함수들을 묶을 수 있습니다. 앞서 언급했듯이, tests 디렉토리 내의 각 파일은 고유의 개별적인 크레이트인 것처럼 컴파일됩니다.

각 통합 테스트 파일을 고유한 크레이트인 것 처럼 다루는 것은 여러분의 크레이트를 이용하게 될 사용자들의 방식과 더 유사하게 분리된 스코프를 만들어 내기에 유용합니다. 하지만, 이는 src 내의 파일들이 동일한 동작을 공유하는 것을 tests 디렉토리 내의 파일들에서는 할 수 없음을 의미하는데, 이는 여러분이 7장에서 코드를 모듈과 파일로 나누는 법에 대해 배웠던 것입니다.

만일 여러분이 여러 개의 통합 테스트 파일들 내에서 유용하게 사용될 헬퍼 함수들 묶음을 가지고 있으며, 이들을 공통 모듈로 추출하기 위해 7장의 "모듈을 다른 파일로 옮기기"절에 있는 단계를 따르는 시도를 한다면, 이러한 tests 디렉토리 내의 파일에 대한 이색적인 동작 방식은 가장 주목할 만 점입니다. 이를테면, 만일 우리가 tests/common.rs 이라는 파일을 만들어서 그 안에 아래와 같이 setup이라는 이름의 함수를 위치시키고, 여기에 여러 테스트 파일들 내의 여러 테스트 함수로부터 호출될 수 있기를 원하는 어떤 코드를 집어넣는다면:

Filename: tests/common.rs


# #![allow(unused_variables)]
#fn main() {
pub fn setup() {
    // 여러분의 라이브러리 테스트에 특화된 셋업 코드가 여기 올 것입니다
}
#}

만약 테스트를 다시 실행시키면, 비록 이 코드가 어떠한 테스트 함수도 담고 있지 않고, setup 함수를 다른 어딘가에서 호출하고 있지 않을지라도, common.rs 파일을 위한 테스트 출력 내의 새로운 섹션을 보게 될 것입니다:

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/common-b8b07b6f1be2db70

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/integration_test-d993c68b431d39df

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

running 0 tests이 표시되는 테스트 출력이 보이는 common을 만드는 건 우리가 원하던 것이 아닙니다. 우리는 그저 다른 통합 테스트 파일들에서 어떤 코드를 공유할 수 있기를 원했지요.

common이 테스트 출력에 나타나는 것을 막기 위해서는, tests/common.rs을 만드는 대신, tests/common/mod.rs를 만듭니다. 7장의 "모듈 파일 시스템의 규칙"절에서 서브모듈을 가지고 있는 모듈의 파일들을 위해 module_name/mod.rs라는 이름 규칙을 이용했었고, 여기서 common에 대한 서브모듈을 가조기 있지는 않지만, 이러한 방식으로 파일명을 정하는 것이 러스트에게 common 모듈을 통합 테스트 파일로 취급하지 않게끔 전달해줍니다. setup 함수 코드를 tests/common/mod.rs로 옮기고 tests/common.rs 파일을 제거하면, 테스트 출력에서 해당 섹션이 더 이상 나타나지 않을 것입니다. tests 디렉토리의 서브 디렉토리 내의 파일들은 개별적인 크레이트처럼 컴파일되지도, 테스트 출력의 섹션을 갖지도 않습니다.

tests/common/mod.rs를 만든 뒤에는, 어떤 통합 테스트 파일에서라도 이를 모듈처럼 쓸 수 있습니다. 아래에 tests/integration_test.rs 내에 it_adds_two 테스트로부터 setup 함수를 호출하는 예제가 있습니다:

Filename: tests/integration_test.rs

extern crate adder;

mod common;

#[test]
fn it_adds_two() {
    common::setup();
    assert_eq!(4, adder::add_two(2));
}

mod common; 선언은 Listing 7-4에서 보여주었던 모듈 선언과 동일한 점을 주목하세요. 그런 다음 테스트 함수 내에서 common::setup() 함수를 호출 할 수 있습니다.

바이너리 크레이트를 위한 통합 테스트

만약 우리의 프로젝트가 src/lib.rs 파일이 없고 src/main.rs 파일만 갖고 있는 바이너리 프로젝트라면, tests 디렉토리 내에 통합 테스트를 만들어서 src/main.rs에 정의된 함수를 가져오기 위하여 extern crate를 이용할 수 없습니다. 오직 라이브러리 크레이트만 다른 크레이트에서 호출하고 사용할 수 있는 함수들을 노출시킵니다; 바이너리 크레이트는 그 스스로 실행될 것으로 여겨집니다.

이는 바이너리를 제공하는 러스트 프로젝트들이 src/lib.rs에 위치한 로직을 호출하는 간단한 형태의 src/main.rs를 가지고 있는 이유 중 하나입니다. 이러한 구조와 함께라면, extern crate를 이용하여 중요한 기능들을 커버하도록 하기 위해 통합 테스트가 라이브러리 크레이트를 테스트할 수 있습니다. 만일 중요 기능이 작동한다면, src/main.rs 내의 소량의 코드 또한 동작할 것이고, 이 소량의 코드는 테스트할 필요가 없습니다.

정리

러스트의 테스트 기능은 코드를 변경하더라도 계속하여 우리가 기대한 대로 동작할 것이라는 확신을 주기 위하여 코드가 어떻게 기능하는지 명시하는 방법을 제공합니다. 단위 테스트는 라이브러리의 서로 다른 부분을 개별적으로 시험하며 비공개된 구현 세부사항을 테스트할 수 있습니다. 통합 테스트는 라이브러리의 많은 부분이 함께 작동하는 사용 상황을 다루며, 외부 코드가 사용하게 될 똑같은 방식대로 테스트하기 위해 그 라이브러리의 공개 API를 이용합니다. 비록 러스트의 타입 시스템과 소유권 규칙이 몇 가지 종류의 버그를 방지하는데 도움을 줄지라도, 테스트는 여러분의 코드가 어떻게 동작하기를 기대하는지와 함께 해야 하는 논리 버그를 잡는 일을 도와주는 데에 있어 여전히 중요합니다.

이 장과 이전 장들의 지식을 합쳐서 다음 장의 프로젝트 작업을 해봅시다!

I/O 프로젝트: 커맨드 라인 프로그램 만들기

이 장에서 우리는 지금까지 배운 많은 내용을 요약 정리하고 몇 가지 표준 라이브러리 기능을 탐색하고자 합니다. 현재 우리가 보유한 러스트 실력을 연습하기 위한 커맨드 라인 툴을 만들고 파일, 커맨드 라인 입출력 작업을 해보게 될 것 입니다.

러스트는 성능, 안전성, '단일 바이너리'로 출력, 그리고 교차 플랫폼 지원으로 커맨드 라인 툴을 제작하기 좋은 언어입니다. 그러니 우리는 고전적인 커맨드 라인 툴 grep을 우리 자체 버전으로 만들어 볼 것입니다. Grep은 "정규 표현식 검색 및 인쇄"의 약어 입니다. grep의 간단한 사용 예로 다음의 단계를 거쳐 지정된 파일에서 지정된 문자를 검색합니다.

  • 인자로 파일 이름과 문자를 취합니다.
  • 파일을 읽어들입니다.
  • 문자 인자를 포함하는 파일의 행들을 찾습니다.
  • 해당 라인들을 표시합니다.

우리는 또한 환경 변수를 사용하는 방법과 표준 출력 대신 표준 에러로 표시하는 방법을 다루고자 합니다. 이러한 기법들은 일반적으로 커맨드 라인 도구들에서 사용됩니다.

한 러스트 커뮤니티 멤버인 Andrew Gallant가 이미 grep의 전체 기능이 구현됐으면서도 월등히 빠른 ripgrep을 만들었습니다. 이에 비해 우리의 grep은 훨씬 간단하게 만들 것 입니다, 이번 장에서 ripgrep과 같은 실제 프로젝트를 이해하는데 필요한 배경지식을 제공합니다.

이 프로젝트는 우리가 지금까지 학습한 다양한 개념을 종합하게 될 겁니다:

  • 구조척 코드 (7장 모듈 편에서 배운 내용)
  • 벡터와 문자열의 사용 (8장 콜렉션)
  • 에러 처리 (9장)
  • 특성과 생명주기를 적절히 사용하기 (10장)
  • 테스트 작성 (11장)

또한 우리는 클로저, 반복자, 특성 개체를 간단히 소개하고자 합니다. 이는 13장과 17장에서 상세히 다룰 겁니다.

언제나처럼 cargo new를 통해 새로운 프로젝트를 생성합시다. 새 프로젝트의 이름을 greprs로 이름 지어서 시스템에 이미 존재하는 grep와 구분짓도록 하겠습니다:

$ cargo new --bin greprs
     Created binary (application) `greprs` project
$ cd greprs

커맨드라인 인자 허용하기

우리의 첫 번째 작업은 greprs가 두 개의 커맨드라인 인자를 받을 수 있도록 하는 것 입니다: 파일이름과 검색할 문자. 즉, cargo run을 통해 함께 우리의 프로그램을 수행시킬 때, 검색할 문자와 검색할 파일의 경로를 사용할 수 있도록 하고자 합니다, 다음처럼 말이죠:

$ cargo run searchstring example-filename.txt

현재로서는, cargo new를 통해 생성된 프로그램은 우리가 입력한 인자를 모두 무시합니다. crates.io 라이브러리에 커맨드라인 인자들을 받아들이도록 도와줄 라이브러리가 이미 존재하지만, 우리 스스로 이를 구현해봅시다.

인자값 읽어들이기

우리 프로그램에 전달된 커맨드라인 인자의 값을 얻으려면 Rust의 표준 라이브러리에서 제공되는 함수를 호출해야합니다: std::env::args. 이 함수는 반복자(iterator) 형식으로 커맨드라인 인자들을 우리 프로그램에 전달해줍니다. 우리는 아직 반복자에 대해 다루지 않았고, 13장에서 다룰 예정이니, 지금은 반복자에 대해서 두 가지 성질만 알고 갑시다.

  1. 반복자는 하나의 연속된 값을 생성합니다.
  2. 반복자에 collect 함수 호출을 통해 반복자가 생성하는 일련의 값을 벡터로 변환할 수 있습니다.

한번 해볼까요? 항목 12-1의 코드를 사용하여 모든 커맨드라인 인자들을 벡터 형태로 greprs로 전달해봅시다.

Filename: src/main.rs

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}

항목 12-1: 커맨드라인 인자를 벡터 형태로 모으고 그들을 출력하기.

가장 먼저, 우리는 std::env 모듈을 use를 통해 모듈 범위 내로 가져와서 그 안의 args 함수를 호출할 수 있도록 합니다. std::env::args 함수는 두 단계 모듈들로 중첩된 호출임을 주지하세요. 7장에서 우리가 이야기 나눴듯이, 원하는 함수가 두 개 이상의 중첩된 모듈에 있는 경우에는 함수 자체가 아닌 부모 모듈을 범위로 가져오는게 일반적입니다.

이런 방식은 우리가 std::env의 다른 함수를 사용하기 용이하도록하며 덜 모호합니다. use std::env::args;를 사용하여 args처럼 함수를 호출하면 현재 모듈에 이 함수가 정의된 것처럼 착각할 수 있습니다.

참고: 어떤 인자가 잘못된 유니코드를 포함하고 있으면 std::env::args는 패닉을 발생합니다. 유효하지 않은 유니코드를 포함한 인자를 허용해야 하는 경우에는 std::env::args_os를 대신 사용하도록 하세요. 이 함수는 String대신 OsString 값을 반환합니다. OsString 값은 플랫폼마다 다르며 String 값보다 다루기가 더 복잡하기 때문에 여기서는 std::env::args를 사용하여 좀더 단순화 했습니다.

main의 첫 번째 줄에서, 우리가 호출한 env::args, 그리고 동시에 사용한 collect는 반복자가 가진 모든 값들을 벡터 형태로 변환하여 반환합니다. collect 함수는 많은 종류의 콜렉션들과 사용될 수 있기 때문에, 우리가 원하는 타입이 문자열 벡터라고 args의 타입을 명시합니다. Rust에서 타입 명시를 할 필요는 거의 없지만, Rust는 우리가 원하는 콜렉션의 타입을 추론 할 수 없기 때문에 collect는 타입을 명시할 필요가있는 함수 중 하나입니다.

마지막으로, 우리는 디버그 형식자인 :?으로 벡터를 출력합니다. 인자 없이, 그리고 두 개의 인자들로 우리의 코드를 실행시켜 봅시다.

$ cargo run
["target/debug/greprs"]

$ cargo run needle haystack
...snip...
["target/debug/greprs", "needle", "haystack"]

벡터의 첫 번째 값은 바이너리의 이름 인 "target / debug / minigrep"입니다. 이것은 C에서 인수 목록의 동작을 일치시키고 프로그램은 실행시 호출 된 이름을 사용하게합니다. 메시지를 인쇄하거나 프로그램을 호출하는 데 사용 된 명령 줄 별칭을 기반으로 프로그램의 동작을 변경하려는 경우 프로그램 이름에 액세스하는 것이 편리하지만이 장의 목적을 위해 무시할 것입니다 우리가 필요로하는 두 가지 주장 만 저장하면됩니다.

벡터의 첫 값이 "target/debug/greprs"으로 바이너리의 이름임을 알 수 있습니다. 왜 그런지에 대한 내용은 이번 장을 넘어가니, 우리가 필요한 두 인자를 저장하였음을 기억하면 되겠습니다.

변수에 인자 값들 저장하기.

인자값들이 들어있는 벡터의 값들을 출력하는 것을 통해 우리의 프로그램에서 커맨드라인 인자의 원하는 값에 접근하는 것이 가능하다는 것을 상상할 수 있습니다. 다음은 정확히 우리가 원하는 방식이 아니지만, 두개의 인자값을 변수로 저장하여 그 값들을 우리의 프로그램에서 사용할 수 있도록 합니다.

항목 12-2대로 해봅시다:

Filename: src/main.rs

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let filename = &args[2];

    println!("Searching for {}", query);
    println!("In file {}", filename);
}

항목 12-2: 쿼리와 파일이름 인자를 보관하는 두 변수를 만듭니다.

우리가 벡터를 출력했을 때 봤듯이, 프로그램의 이름이 벡터의 첫 번째 값으로 args[0]에 저장되어 있으니, 우리는 1번째 색인부터 접근하면 됩니다. greprs의 첫 번째 인자는 검색하고자 하는 문자열이므로, 우리는 첫 번째 인자의 참조자를 query에 저장합니다. 두 번째 인자는 파일이름이니, 두 번째 인자의 참조자를 변수 filename에 저장합니다.

임시적으로 우리는 이 값들을 단순 출력하고 있으니, 우리의 코드가 우리가 원하는 방식으로 동작하고 있다는 것을 증명하기 위해, 이 프로그램을 testsample.txt를 인자로 주고 다시 실행해봅시다:

$ cargo run test sample.txt
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs test sample.txt`
Searching for test
In file sample.txt

훌륭하게, 동작하네요! 우리는 인자 값들을 우리가 원하는 변수에 정확히 저장했습니다. 후에 사용자가 아무런 인자를 넣지 않은 상황을 다루기 위해 오류처리를 추가해볼 겁니다. 하지만 당장은 그것보다 파일 읽기 기능을 추가해봅시다.

파일 읽기

다음으로, 우리는 커맨드 라인 인자 파일이름으로 지정된 파일을 읽어볼 겁니다. 먼저, 함께 테스트 할 샘플 파일이 필요합니다. 'greprs'가 동작하는 것을 확신할 수 있기 위해 가장 좋은 종류의 파일은 몇 개의 반복되는 단어의 다수의 줄에 걸쳐 존재하는 작은 양의 텍스트입니다. 항목 12-3의 에밀리 딕킨스 시는 잘 작동할 겁니다. poem.txt로 명명된 파일을 당신의 프로젝트 최 상위에 생성하고 시를 입력합시다 "I'm nobody! Who are you?":

Filename: poem.txt

I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us — don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

항목 12-3: 테스트 용으로 적합한 에밀리 딕킨슨의 시 "I'm nobody! Who are you?"

With that in place, edit src/main.rs and add code to open the file as shown in Listing 12-4:

Filename: src/main.rs

use std::env;
use std::fs::File;
use std::io::prelude::*;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let filename = &args[2];

    println!("Searching for {}", query);
    println!("In file {}", filename);

    let mut f = File::open(filename).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    println!("With text:\n{}", contents);
}

항목 12-4: 두 번째 인자로 특정된 파일의 내용 읽어들이기

먼저, 우리는 use문 몇 개를 추가하여 표준 라이브러리에서 관련 있는 부분을 가져옵니다: 우리는 파일 관련하여 std::fs::File과, 파일 I/O를 포함한 I/O 작업을 위해 유용한 다양한 특성이 있는 std::io::prelude::*이 필요합니다.

Rust가 가진 지정된 것들을 영역 내로 가져오는 일반적인 도입부와 동일하게, std::io 모듈은 당신이 I/O 작업을 할 때 필요할만한 일반적인 것들에 대한 그 자신만의 도입부를 갖습니다. 기본적인 도입부와는 다르게, 우리는 반드시 std::io의 도입부를 명시적으로 use해야 합니다.

main에서, 우리는 다음 세 가지를 추가했습니다: 첫 째, File::open함수를 호출하고 filename값을 전달하여 파일을 변경할 수 있는 핸들을 얻습니다. 두 번째로, contents라는 이름의 빈 String 가변 변수를 만들었습니다. 이 변수는 우리가 읽어들인 내용을 보과하기 위한 용도로 사용될 겁니다. 셋 째, 우리가 만들어 둔 파일 핸들에 read_to_string을 호출하여 가변 참조를 contents의 인자로 전달합니다.

이후, 임시로 println!을 추가하여 contents의 값을 출력함으로서 파일을 읽어들인 이후 우리 프로그램이 제대로 동작했는지 확인할 수 있습니다.

아무 문자나 첫 번째 커맨드라인 인자로 입력하고(우리가 아직 검색 부분을 구현하지 않았기 때문에) 두 번째는 우리가 만들어 둔 poem.txt 파일로 입력하여 이 코드를 실행해봅시다.

$ cargo run the poem.txt
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us — don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

좋군요! 우리의 코드가 파일 내용을 읽고 출력했습니다. 우리 프로그램은 몇 가지 결점이 있습니다: main 함수는 많은 책임을 지고(역주:단일책임의법칙 참고), 우리가 할 수 있는 에러처리를 하지 않았습니다. 아직 우리의 프로그램이 작기 때문에, 이 결점들은 큰 문제가 아닐 수도 있습니다. 하지만 우리 프로그램 커져가면, 점점 이를 깔끔하게 수정하기 어렵게 됩니다. 프로그램의 개발 초기 단계에 리팩토링을 하면 코드의 양이 적은만큼 리팩토링을 하기 훨씬 쉬워지기 때문에 훌륭한 단련법 입니다. 그러니 지금 해봅시다.

모듈성과 에러처리의 향상을 위한 리팩토링

우리 프로그램을 향상시키기 위해 네 가지 수정하고 싶은 문제가 있는데, 이들은 프로그램을 구조화하고 발생가능한 에러를 처리하는 방식과 관련있습니다.

첫 번째, 우리 main 함수는 현재 두 가지 작업을 수행합니다: 인자들을 분석하고 파일을 열지요. 이런 작은 함수에서, 이건 큰 문제가 안됩니다. 하지만 우리가 계속해서 main함수 안에 프로그램을 작성하여 커지게 되면, main 함수가 처리하는 작업의 수도 늘어나게 될 겁니다. 함수가 갖게되는 책임들만큼, 근원을 파악하기도, 테스트 하기에도, 부분 별로 나누지 않고는 수정하기도 어려워 집니다. 함수는 나뉘어 하나의 작업에 대해서만 책임을 지는 것이 더 좋은 구조입니다.

이 문제는 우리의 두 번째 문제와도 관련이 있습니다: queryfilename
프로그램의 설정을 저장하는 변수이고 fcontents 같은 변수는 프로그램의 논리 수행에 사용됩니다. main이 길어질수록 범위 내에 더 많은 변수 생깁니다. 범위 내에 더 많은 변수가 존재할수록, 각각의 변수를 추적하기 힘들어집니다. 목적을 분명히 하기 위해 설정 변수를 그룹화하여 하나의 구조로 결합시키는 것이 좋습니다.

세 번째 문제는 파일 열기가 실패 할 경우expect를 사용하여 오류 메시지를 출력해주는데, 에러 메시지가 파일을 찾을 수 없음 밖에 없습니다. 파일이 존재하지 않는 경우 외에도 파일 열기가 실패하는 경우들이 있습니다. 예를 들어 파일은 존재하지만 파일을 열 수있는 권한이 없을 수 있습니다. 현재는 이런 상황에도 파일을 찾을 수 없음 이란 오류 메시지를 출력하여 사용자에게 잘못된 조언을 해주게 됩니다.

넷째, 우리는 서로 다른 오류를 다루기 위해 expect를 반복적으로 사용하고 있습니다. 헌데 만약 사용자가 충분한 인수를 지정하지 않고 프로그램을 실행하면 Rust의 "index out of bounds" 오류가 발생하는데 이는 문제를 명확하게 설명하지 않습니다. 우리가 모든 오류처리 코드를 한 군데 모아놓으면 후에 관리자는 오류처리 로직을 변경해야 할 때 오직 이 곳의 코드만 참고하면 되니 더 좋죠. 또한, 모든 오류 처리 코드를 한 곳에 저장하면 우리가 최종 사용자에게 도움이 되는 메시지를 출력하고 있는지 확신하는데도 도움이 됩니다.

이런 문제들을 우리 프로젝트를 리팩토링하여 해결해보도록 하겠습니다.

바이너리 프로젝트를 위한 핵심기능(concern) 나누기

main 함수가 여러 작업에 책임을 갖게 되는 구조적 문제는 많은 바이너리 프로젝트에서 공통적입니다. 그래서 Rust 커뮤니티는 main이 커지기 시작할 때 바이너리 프로그램의 핵심기능을 나누기 위한 가이드라인 프로세스를 개발했습니다. 프로세스에는 다음 단계가 있습니다:

  1. 당신의 프로그램을 main.rslib.rs 로 나누고 프로그램의 로직을 lib.rs 으로 옮깁니다.
  2. 커맨드라인 파싱 로직이 크지 않으면, main.rs 에 남겨둬도 됩니다.
  3. 커맨드라인 파싱 로직이 복잡해지기 시작할거 같으면, main.rs 에서 추출해서 lib.rs 로 옮기세요.
  4. 이런 절차를 통해 main 함수에는 다음의 핵심 기능들만 남아있어야 합니다:
    • 인자 값들로 커맨드라인을 파싱하는 로직 호출
    • 다른 환경들 설정
    • lib.rsrun 함수 호출
    • run이 에러를 리턴하면, 에러 처리.

이 패턴이 핵심기능을 분리하는데 관한 모든 것입니다: main.rs 는 프로그램 실행을 담당하고, lib.rs는 맡은 작업에 관한 로직을 담당합니다. main 함수는 직접 테스트 할 수 없지만, 이런 구조로 lib.rs 으로 프로그램의 모든 함수와 로직을 옮긴 후에는 테스트가 가능해집니다. main.rs에는 읽어서 옳바른지 여부를 검증할 수 있을 정도로 적은 코드만을 남겨두도록 합니다. 다음의 과정을 거치며 재작업을 해봅시다.

인자 파서의 추출

먼저 우리는 커맨드라인 인자를 분석하는 기능을 추출할 겁니다. 항목 12-5에서 main의 시작 부분이 새로운 함수 parse_config를 호출하는 것을 볼 수 있을텐데, 이는 아직은 src/main.rs에 정의되어 있을 겁니다.

Filename: src/main.rs

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, filename) = parse_config(&args);

    // ...snip...
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let filename = &args[2];

    (query, filename)
}

Listing 12-5: Extract a parse_config function from main

우리는 아직 커맨드라인 인자들을 벡터로 수집하고 있는데, 인덱스 1의 인수 값을 변수 query 에, 인덱스 2의 인수 값을 main 함수 내의 변수 filename에 할당하는 대신에 전체 벡터를 parse_config 함수로 전달합니다. parse_config 함수는 어디에 위치한 인자가 어떤 변수에 대입되는지에 대한 로직을 보유하고, 그 값들을 main으로 되돌려 줍니다. 우리는 여전히 queryfilename변수를 main에 생성하지만, main은 더 이상 커맨드라인 인자와 변수간의 상관 관계를 책임지지도 알아야 할 필요도 없죠.

이것이 우리가 작은 프로그램을 유지하기 위한 과도한 행동으로 보일수도 있지만, 우리는 조금씩 점진적으로 리팩토링을 진행하고 있습니다. 이런 변화를 준 뒤에는, 프로그램을 다시 실행해 인자의 파싱이 정상적으로 동작하고 있는지 확인해보십시오. 진행 상황을 자주 확인하면 문제가 생겼을 때 원인을 파악하는데 도움이 됩니다.

설정 변수들을 그룹짓기

우리는 이 함수의 기능을 더 향상시키기 위해 또 다른 작은 행동을 할 수 있습니다. 현재 우리는 튜플을 반환하고 있는데, 그 시점에 즉시 튜플을 개별된 부분으로 나눌 수가 없습니다. 이는 우리가 아직은 제대로 된 추상화를 하지 못하고 있다는 신호일 수 있습니다.

또 다른 의미로는 config의 부분인 parse_config에 향상시킬 지점이 있다는 것으로, 우리가 반환하는 두 개의 값은 관련되어 있으며 모두 하나의 설정 값에 대한 부분이죠. 우리는 현재 두 값을 튜플로 그룹화하는 것 이외의 다른 의미를 전달하지 않습니다. 두 값을 하나의 구조체에 넣고 각 구조체 필드에 의미있는 이름을 지정할 수 있습니다. 이렇게 하면 이 코드의 향후 유지 보수 담당자가 서로 다른 값이 서로 어떻게 관련되어 있고 그 목적이 무엇인지 쉽게 이해할 수 있습니다.

Note: some people call this anti-pattern of using primitive values when a complex type would be more appropriate primitive obsession.

항목 12-6에서 queryfilename을 필드로 갖는 Config란 구조체 정의가 추가된 것을 볼 수 있습니다. 우리는 또한 parse_config 함수를 변경하여 Config 구조체의 객체를 반환하게 변경하였으며, main에서 별개의 변수가 아닌 구조체의 필드를 사용하도록 변경했습니다.

Filename: src/main.rs

# use std::env;
# use std::fs::File;
#
fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    let mut f = File::open(config.filename).expect("file not found");

    // ...snip...
}

struct Config {
    query: String,
    filename: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let filename = args[2].clone();

    Config { query, filename }
}

Listing 12-6: Refactoring parse_config to return an instance of a Config struct

이제 parse_config의 선언은 Config 값을 반환한다는 것을 알려줍니다. parse_config 의 내부에서는 argsString값을 참조하는 문자열 조각을 반환했었지만, 이제는 Config를 정의하고 자체 String의 값을 포함하도록 선택했습니다. mainargs변수는 인자 값들의 소유주로 parse_config에는 그들을 대여해줄 뿐 입니다. 그렇기에 만약 Configargs의 값들에 대한 소유권을 가지려고 시도하면 Rust의 대여 규칙을 위반하게 됩니다.

우리가 String 데이터를 관리하는 방식은 여러가지가 있겠습니다만, 가장 쉽고 약간 비효율적인 방법은 clone 메소드를 호출하는 겁니다. 이 방식은 Config 객체에서 소유하게 할 data 전체에 대한 복사본을 만들 것이며, 이런 방식은 참조만 보관하는 것에 비해 약간 더 많은 비용과 메모리가 소비됩니다. 하지만 데이터의 복제본을 만드는 방식은 우리가 참조의 생명주기를 관리하지 않아도 되기 때문에 우리의 코드를 매우 직관적이게 합니다. 그래서 이런 상황에서는 약간의 성능을 포기하고 간소함을 유지하는 것이 매우 가치있는 거래입니다.

clone 사용의 기회비용

많은 Rust 사용자들은 런타임 비용 때문에 소유권 문제를 수정하기 위해 clone을 사용하지 않는 경향이 있습니다. 13장 이터레이터에서, 이런 상황에서보다 효율적인 메소드를 사용하는 법을 배우겠지만, 지금은 한 번만 clone하며 query와 filename이 매우 작기 때문에 몇 개의 문자열을 clone하여 진행하는 것이 좋습니다. 첫 번째 단계에서는 코드를 최대한 최적화하는 것보다 약간 비효율적이더라도 넘어가는게 좋습니다. Rust에 대한 경험이 많을수록 바람직한 방법으로 곧장 진행할 수 있을 겁니다. 지금은 clone을 호출하는 것이 완벽한 선택입니다.

parse_config에 의해 반환된 Config의 객체를 config라는 변수에 넣고 이전에 별도로 queryfilename이란 이름으로 나뉘어 있던 변수 대신 Config 구조체의 필드를 사용하도록 main을 업데이트했습니다.

우리의 코드는 이제 보다 분명하게 queryfilename이 연관되어 있으며 이들의 목적이 프로그램이 어떻게 동작할지에 대한 설정이라는 의도를 전달할 수 있습니다. 이 값을 사용하는 모든 코드는 그들의 의도에 맞게 지정된 필드를 config 객체에서 찾을 수 있습니다.

Config를 위한 생성자 만들기.

지금까지 우리는 main에서 parse_config함수로 커맨드라인 인자를 파싱하는 로직을 추출했습니다. 이를 통해 우리 코드에서 queryfilename값이 연관되어 있고 그 연결성이 전달되어야 한다는 것을 알았습니다. 그래서 우리는 Config 구조체를 추가하고 그 의도와 목적에 맞게 queryfilename을 명명했으며 parse_config 함수에서 변수의 이름을 구조체 필드 이름으로 반환 할 수 있게 했습니다.

그래서 이제 parse_config 함수의 목적은 Config 객체를 생성하는 것인데, 우리는 parse_config라는 평범한 함수를 Config 구조체와 관련된 new라는 함수로 변경 할 수 있습니다. 이런 변경은 우리의 코드를 보다 자연스럽게 만들어 줍니다:String::new를 호출하여 String형의 객체를 생성하는 것처럼 표준 라이브러리들의 객체를 생성할 수 있습니다. 그리고 parse_configConfig와 연관된 new함수로 변경하게 되면, 우리는 Config의 객체를 Config::new를 호출하여 생성할 수 있게 됩니다. 항목 12-7는 우리가 해야할 변동사항 보여줍니다.

Filename: src/main.rs

# use std::env;
#
fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    // ...snip...
}

# struct Config {
#     query: String,
#     filename: String,
# }
#
// ...snip...

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let filename = args[2].clone();

        Config { query, filename }
    }
}

Listing 12-7: Changing parse_config into Config::new

우리는 main을 갱신하여 parse_config를 호출하는 대신 Config::new를 호출하게 되었습니다. 우리는 parse_config의 이름을 new로 바꾸고 그를 impl블록 안으로 옮겼는데, 이를 통해 new함수가 Config와 연결되게 됩니다. 다시 컴파일을 하고 제대로 동작하는지 확인해보도록 합시다.

에러 처리 수정하기

이번에는 우리의 에러 처리를 수정해 볼 겁니다. 만일 args 벡터가 3개 미만의 아이템을
가지고 있을 때 인덱스 2 혹은 3의 값에 접근하려는 시도를 하면 프로그램은 패닉을 일으키게 된다고 했던 것을 상기시켜 드립니다. 프로그램을 인자 없이 실행해보시면; 다음같이 될 겁니다.

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs`
thread 'main' panicked at 'index out of bounds: the len is 1
but the index is 1',  /stable-dist-rustc/build/src/libcollections/vec.rs:1307
note: Run with `RUST_BACKTRACE=1` for a backtrace.

index out of bounds: the len is 1 but the index is 1 줄은 프로그래머를 위해 의도된 에러 메시지이지, 최종 사용자에게는 무슨 일이 있었는지 무엇을 해야 하는지 이해하는데 아무런 도움이 되지 않습니다. 당장 한번 고쳐보겠습니다.

에러 메시지 향상시키기

항목 12-8에서 new함수에 검사를 추가하여 인덱스 12에 접근하기 전에 조각이 충분한 길이인지를 확인합니다. 조각이 충분히 길지 않다면, 프로그램은 더 좋은 에러메시지 index out of bounds를 보여주고 패닉을 일으킵니다:

Filename: src/main.rs

// ...snip...
fn new(args: &[String]) -> Config {
    if args.len() < 3 {
        panic!("not enough arguments");
    }
    // ...snip...

항목 12-8: 인자의 숫자가 몇 개인지 검증 추가

이것은 항목 9-8에서 작성한 Guess::new함수와 유사합니다. 이 함수는 value인수가 유효한 값의 범위를 벗어난 경우 panic!을 호출했습니다. 값의 범위를 검사하는 대신에, 우리는args의 길이가 적어도 3개인지 검사하면, 함수의 나머지 부분은 이 조건이 이미 충족되었다는 가정 하에서 동작할 수 있습니다. args가 3개 보다 적은 아이템을 가진다면, 이 조건은 true가 되고 우리는 panic! 매크로를 호출해 프로그램을 즉시 종료 시킬겁니다.

이런 몇 줄의 추가 코드들을 new상에 추가하고, 우리 프로그램을 아무 인자도 없이 다시 실행시키면 다음과 같은 에러를 볼 수 있을 겁니다.

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs`
thread 'main' panicked at 'not enough arguments', src/main.rs:29
note: Run with `RUST_BACKTRACE=1` for a backtrace.

이 결과 더 합리적인 좋은 오류 메시지가 표시됩니다. 그러나 사용자에게 제공하고 싶지 않은 추가 정보가 있습니다. 따라서 항목 9-8에서 사용한 기술을 사용하는 것은 여기선 최선의 방법은 아닙니다. panic!에 대한 호출은 9장에서 논의했던 것처럼 사용 방법에 대한 문제가 아닌 아니라 프로그래밍 관련 문제에 더 적합합니다. 대신, 우리는 9장에서 배운 다른 기법으로 Result를 반환하는 것을 성공이나 오류를 나타낼 수 있습니다.

new에서 panic!을 호출하는 대신 Result를 반환하기.

우리는 Result를 반환 값으로 선택하여 성공인 경우에는 Config 객체를 포함시키고 에러가 발생한 경우에는 문제가 무엇인지 설명할 수 있게 만들 수 있다. Config::newmain과 상호작용할 시에, 우리는 Result를 사용하여 문제가 있다고 신호할 수 있다. 그리고main에선 Err의 값을 사용자들에게 보다 실용적인 방식으로 변환하여 보여줄 수 있다. thread 'main' 으로 시작하는 문자들과 panic!을 사용해서 보여지는 RUST_BACKTRACE관련 메시지 없이.

항목 12-9에서 당신이 변경해야 할 Config::new의 반환 값과 Result를 반환하기 위한 함수 본문을 보여줍니다:

Filename: src/main.rs

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config { query, filename })
    }
}

항목 12-9: Config::new에서 Result반환

우리의 new 함수는 이제 성공 시에는 Config객체가 에러 시에는 &'static str가 포함된 Result를 반환하게 됩니다. 10장의 "The Static Lifetime"에서 `&'static str'이 문자열 리터럴이라고 다뤘는데, 이게 현재 우리의 에러 타입입니다.

우리는 new함수의 본문에서 두 가지 변경을했습니다 : 사용자가 충분한 인수를 전달하지 않을 때 panic!을 호출하는 대신 Err값을 반환하고 Config를 반환할 때는 Ok로 포장하여 반환 합니다. 이런 변경으로 인해 함수는 새로운 타입 선언을 갖게 됩니다.

Config::newErr값을 반환하게 함으로써, main함수는 new함수로부터 반환된 Result값을 처리하고 에러 상황에 프로세스를 더 깨끗하게 종료 할 수 있습니다.

Config::new를 호출하고 에러 처리하기

에러 케이스를 처리하고 사용자-친화적인 메시지를 출력하기 위해서, 항목 12-10에서처럼 Config::new가 리턴하는 Result를 처리하기 위해 main을 갱신해야 합니다. 그리고 우리 커맨드라인 프로그램을 panic!으로 0이 아닌 값을 발생시킬 때에는 종료시켜야 하므로 직접 구현해보도록 합시다. 0이 아닌 종료 값은 우리 프로그램을 호출한 프로그램에게 우리의 프로그램이 에러 상태로 종료되었음을 알리는 규칙입니다.

Filename: src/main.rs

use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    // ...snip...

항목 12-10: new Config가 실패했을 때 에러 코드와 함께 종료시키기

이 목록에서 우리는 이전에 다루지 않았던 메소드를 사용하고 있습니다: unwrap_or_else는 표준 라이브러리에 의해 Result <T, E>에 정의되어 있습니다. unwrap_or_else를 사용하면 panic!이 아닌 에러 처리를 직접 정의 할 수 있습니다. ResultOk 값이면, 이 메소드의 동작은 unwrap과 유사합니다 : 그것은 Ok로 포장한 내부 값을 반환합니다. 그러나 Err값이면 메소드는 closure의 코드를 호출합니다. closure는 익명의 함수로 unwrap_or_else에 인수로 전달됩니다. 13장에서 클로저에 대해 더 자세히 다룰 것입니다. 여기서 알아 두어야 할 것은 unwrap_or_elseErr의 내부 값, 이번 경우에는 항목 12-9에서 우리가 추가한 정적 문자열인 not enough arguments을, 수직파이프 사이에 위치하는 err로 인자로서 우리의 클로저로 전달한다는 겁니다. 클로저에 있는 코드는 이런 과정을 거쳐 실행 시에 err값을 사용할 수 있습니다.

우리는 새 use줄을 추가하여 process를 공유 라이브러리에서 import했습니다. 에러 상황에 실행될 클로저의 코드는 단 두 줄 입니다. 에러 값을 출력해주고 process::exit를 호출합니다. process::exit함수는 프로그래을 즉시 중단시키고 종료 상태 코드로 전달받은 값을 반환합니다. 이것은 항목 12-8에서 사용한 panic!기반의 처리 방식과 유사해 보이지만, 더이상 필요하지 않은 출력을 하지 않죠. 해볼까요?

$ cargo run
   Compiling greprs v0.1.0 (file:///projects/greprs)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48 secs
     Running `target/debug/greprs`
Problem parsing arguments: not enough arguments

훌륭하네요! 이 출력은 우리 사용자들에게 훨씬 친화적입니다.

run 함수 추출하기

이제 환경 설정 파싱 리팩토링을 마무리 했습니다. 우리 프로그램의 로직으로 돌아갑시다. 우리가 "바이너리 프로젝트에서 핵심 기능의 분리"절에서 논의한 과정에 따라, 우리는 main함수에 구성 설정 또는 오류 처리와 관계 없는 남아있는 모든 로직들을 담고있는 run함수를 추출 할 겁니다. 이 과정이 종료되면, main은 간결해져 쉽게 검증할 수 있어지고, 우리는 다른 모든 로직에 대한 테스트를 작성할 수 있을 겁니다.

항목 12-11 추출된 run 함수를 보여줍니다. 현재 우리는 함수를 추출하여 src/main.rs에 함수를 정의하는 작고 점진적 개선만 수행하고 있습니다.

Filename: src/main.rs

fn main() {
    // ...snip...

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    run(config);
}

fn run(config: Config) {
    let mut f = File::open(config.filename).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    println!("With text:\n{}", contents);
}

// ...snip...

항목 12-11: 남은 프로그램 로직을 run 함수로 추출하기

이제 run함수에는 main에 잔존하는 파일을 읽는 것부터 나머지 모든 로직이 포함됩니다. run 함수는 Config 객체를 인수로 취합니다.

run 함수에서 에러 반환하기

나머지 프로그램 로직을 main이 아닌run 함수로 분리하면, Listing 12-9의 Config::new처럼 에러 처리를 향상시킬 수 있습니다. expect를 호출하여 프로그램을 패닉 상태로 만드는 대신, run함수는 무언가가 잘못되었을 때 Result <T, E>를 리턴 할 것입니다. 이러면 사용자 친화적인 방법으로 오류를 처리하는 로직을 main으로 통합 할 수 있습니다. 항목 12-12는 run의 선언부와 본문의 변경 사항을 보여줍니다.

Filename: src/main.rs

use std::error::Error;

// ...snip...

fn run(config: Config) -> Result<(), Box<Error>> {
    let mut f = File::open(config.filename)?;

    let mut contents = String::new();
    f.read_to_string(&mut contents)?;

    println!("With text:\n{}", contents);

    Ok(())
}

항목 12-12: run 함수가 Result를 반환하게 바꾸기

우리는 여기서 세 가지 큰 변화를 만들었습니다. 먼저, run 함수의 리턴 타입을 Result <(), Box <Error >>로 바꿨습니다. 이 함수는 이전에 유닛 타입 ()을 반환했으며, 우리는 Ok의 경우 반환할 값으로 이 타입을 유지합니다.

우리의 에러 타입으로, 특성 오브젝트 Box 를 사용합니다 (그리고 상단에 use문으로 std::error::Error를 범위 내로 임포트 해왔습니다). 우리는 특성 오브젝트들을 17장에서 다룰 것입니다. 지금 당장은, Box<Error>는 함수가 Error 특성을 구현하는 타입을 반환한다는 것만 알면 되고, 특별히 어떤 타입이 반환될지에 대해서는 알 필요 없습니다. 이런 방식은 다양한 에러 상황에 다른 타입의 오류 값을 반환 할 수 있는 유연성을 확보할 수 있습니다.

우리가 만든 두 번째 변화는 우리가 9 장에서 이야기했듯이, ?에 대한 expect에 대한 호출을 제거한 것입니다. 에러 시에 panic!을 호출하는 것보다 현재 함수에서 에러 값을 반환하며 호출자가 처리 할 수 ​​있도록 하였습니다.

셋째, 이 함수는 성공 사례에서 Ok값을 반환합니다. 우리는 run 함수의 성공 타입을 선언부에서 ()로 선언했습니다, 이것은 우리가 유닛 타입 값을 Ok 값으로 감쌀 필요가 있음을 의미합니다. 이 Ok (())구문은 조금 이상하게 보일 수 있지만, ()를 사용하는 것과 마찬가지로 이는 사이드이펙트 없이 run을 호출하는 것을 나타내는 관용적인 방법입니다. 우리가 필요로 하는 값을 반환하지 않습니다.

실행시키면, 컴파일 될텐데, 경고를 보여줍니다:

warning: unused result which must be used, #[warn(unused_must_use)] on by default
  --> src/main.rs:39:5
   |
39 |     run(config);
   |     ^^^^^^^^^^^^

Rust는 우리 코드가 오류가 있음을 나타내는 Result 값을 무시한다는 것을 알려줍니다. 우리는 에러가 있는지 아닌지를 확인하지 않고 있고, 컴파일러는 우리에게 아마도 여기에 에러 처리 코드를 작성해야 한다는 것을 상기 시켜줄 것입니다! 당장 바로잡아 봅시다.

Rust는 우리 코드가 오류가 있음을 나타내는 'Result'값을 무시한다는 것을 알려줍니다. 우리는 에러가 있는지 아닌지를 확인하지 않고 있고, 컴파일러는 아마도 여기에 에러 처리 코드를 가지고 있다는 것을 상기 시켜줄 것입니다! 지금 바로 잡아 보자.

main안의 run에서 반환되는 에러 처리하기

우리는 항목 12-10의 Config::new를 사용하여 오류를 처리하는 방식과 비슷한 방법을 사용하여 오류를 검사하고 멋지게 처리합니다. 그러나 약간의 차이점이 있습니다.

Filename: src/main.rs

fn main() {
    // ...snip...

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(e) = run(config) {
        println!("Application error: {}", e);

        process::exit(1);
    }
}

우리는 unwrap_or_else를 호출하기 보다 if let을 사용하여 runErr값을 반환하는지 검사하고 만약 그렇다면 process::exit(1)을 호출합니다. runConfig::newConfig객체를 반환하는 것처럼 우리가 unwrap하기를 원하는 값을 반환하지 않습니다. 왜냐하면 run은 성공하면 ()를 반환하기 때문에, 우리는 에러가 발생한 경우만 신경쓰면 됩니다. 그래서 우리는 unwrap_or_else을 통해 포장을 벗길 필요가 없죠, 값은 무조건 ()일테니까요.

if letunwrap_or_else 함수의 내용은 동일한 경우에 동일한 동작을 합니다, 오류를 출력하고 종료하죠.

라이브러리 크레이트로 코드를 나누기

지금까지 꽤 좋아 보인다! 이제 우리는 src/main.rs 파일을 나눠서 src/lib.rs에 몇 개의 코드를 넣어서 테스트 할 수 있고 작은 src/main.rs 파일을 갖게 될 것입니다.

src/main.rs에 파편으로 존재하는 다음 코드들을 새 파일로 옮겨봅시다. src/lib.rs:

  • run 함수 정의
  • 관련있는use 문들
  • Config의 정의
  • Config::new 함수와 정의

src/lib.rs의 내용은 항목 12-13에서 보이는 것과 같을겁니다.

Filename: src/lib.rs

use std::error::Error;
use std::fs::File;
use std::io::prelude::*;

pub struct Config {
    pub query: String,
    pub filename: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config { query, filename })
    }
}

pub fn run(config: Config) -> Result<(), Box<Error>>{
    let mut f = File::open(config.filename)?;

    let mut contents = String::new();
    f.read_to_string(&mut contents)?;

    println!("With text:\n{}", contents);

    Ok(())
}

항목 12-13: Configrunsrc/lib.rs로 옮기기

우리는 Config의 필드 및 new 메소드와 run 함수에 대해 pub을 자유롭게 사용했습니다. 이제 우리가 테스트 할 수있는 공개 API를 가진 라이브러리 크레이트가 생겼습니다.

바이너리 크레이트에서 라이브러리 크레이트 호출하기

이제 우리는 src/main.rs에 있는 바이너리 크레이트의 범위에 src/lib.rs로 옮긴 코드를 extern crate greprs를 사용하여 가져와야 합니다. 이후 use greprs::Config 행을 추가하여 Config 타입을 범위로 가져오고 항목 12-14와 같이 크레이트 이름으로run 함수 앞에 접두사를 붙입니다.

Filename: src/main.rs

extern crate greprs;

use std::env;
use std::process;

use greprs::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(e) = greprs::run(config) {
        println!("Application error: {}", e);

        process::exit(1);
    }
}

항목 12-14: greprs크레이트를 src/main.rs 범위로 연결하기

라이브러리 크레이트를 바이너리 크레이트에 가져 오려면 extern crate minigrep을 사용합니다. 그런 다음 minigrep::Config줄을 추가하여 Config타입을 범위로 가져오고 run 함수 접두어에 크레이트 이름을 붙입니다. 이를 통해 모든 기능이 연결되어 있어야 하며 작동해야 합니다. cargo run을 실행하여 모든 것이 올바르게 연결되어 있는지 확인하십시오.

아오! 빡시게 작업했네요, 우리의 미래를 우리 스스로가 성공의 방향으로 설정했습니다. 이제 에러를 처리가 훨씬 쉬워졌고, 우리의 코드를 보다 모듈화하였습니다. 거의 모든 작업은 여기 src/lib.rs에서 수행될 겁니다.

새롭게 확보한 모듈성을 통해 이전의 코드로는 하지 못했을 무언가를 쉽게 할 수 있는 이점을 확보했습니다:몇 개의 테스트를 작성해봅시다!

테스트 주도 개발로 라이브러리의 기능 개발하기

src/lib.rs으로 로직을 추출하고 src/main.rs에 인수 수집 및 에러 처리를 남겨 두었으므로 우리의 핵심 기능 코드에 대한 테스트를 작성하는 것이 훨씬 쉬워졌습니다. 커맨드라인에서 바이너리를 실행할 필요없이 다양한 인수를 사용하여 함수를 직접 호출하고 반환 값을 확인할 수 있습니다. 자신이 만든 Config::newrun함수의 기능에 대해 몇 가지 테스트를 작성하면서 자유도를 느껴보세요.

이 섹션에서는 TDD(Test Driven Development) 프로세스에 따라 minigrep에 검색 로직을 추가합니다. 해당 소프트웨어 개발 기법은 다음의 단계를 따릅니다:

  1. 실패할 테스트를 작성하고, 의도한 대로 실패하는지 실행해보세요.
  2. 새 테스트를 통과하기 충분할 정도로 코드를 작성하거나 수정하세요.
  3. 추가하거나 수정하는 정도의 리팩토링을 해보고, 여전히 테스트를 통과하는지 확인해보세요.
  4. 1단계로 반복!

이것은 소프트웨어를 작성하는 여러 가지 방법 중 하나지만 TDD는 코드 설계를 좋은 상태로 유지시켜 줍니다. 코드를 작성하기 전에 테스트를 작성하고 테스트를 통과시키면 높은 테스트 범위를 유지하는데 도움이 됩니다. 테스트 패스를 작성하는 코드를 작성하기 전에 테스트를 작성하면 프로세스 전체에서 높은 테스트 적용 범위를 유지하는 데 도움이 됩니다.

우리는 실제로 파일 내용에서 쿼리 문자열을 검색하고 쿼리와 일치하는 줄의 목록을 생성하는 기능의 구현을 테스트 주도로 개발해 볼 겁니다. 이 기능을 search라는 함수에 추가 할 것입니다.

실패 테스트 작성하기

더 이상 필요하지 않으므로 프로그램의 동작을 확인하는 데 사용했던 src/lib.rs 및 *src/main.rs *에서 println!문을 제거해 봅시다. 그런 다음 src/lib.rs에 11 장에서 했던 것처럼 test 함수가 있는 test 모듈을 추가 할 것입니다. test 함수는 search 함수에 필요한 동작을 지정합니다. 쿼리와 텍스트를 가져 와서 쿼리를 검색하고 쿼리를 포함하는 텍스트의 줄만 반환합니다. 항목 12-15는 아직 컴파일되지 않는 이 테스트를 보여줍니다.

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
# fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
#      vec![]
# }
#
#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(
            vec!["safe, fast, productive."],
            search(query, contents)
        );
    }
}
#}

Listing 12-15: Creating a failing test for the search function we wish we had

이 테스트는 “duct.”라는 문자열을 검색합니다. 우리가 검색하는 텍스트는 세 줄로, 한 줄은 “duct.”를 포함합니다. 우리는 search 함수에서 반환하는 값이 우리가 예상한 줄이어야 한다고 단정했습니다(assert).

테스트가 컴파일되지 않기 때문에 우리는 이 테스트를 실행할 수 없으며 search 함수가 아직 존재하지 않습니다! 이제 우리는 항목 12-16에서 보듯이 항상 빈 벡터를 반환하는 search 함수의 정의를 추가하여 컴파일과 실행하기에 충분한 코드를 추가 할 것입니다. 빈 벡터가 "safe, fast, productive."줄을 포함하는 벡터와 일치하지 않기 때문에 테스트는 컴파일되지만 실패해야 합니다.

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}
#}

항목 12-16: 우리 테스트를 컴파일 하기 위해 필요한 search 정의.

search의 선언부에는 필요한 명시적인 라이프타임 'acontents 인자, 그리고 반환 값과 함께 사용됩니다. 10 장에서 인자의 라이프타임으로 라이프타임 값이 매개변수로 명시된 경우 반환되는 값의 라이프타임도 연결된다고 했던 점을 상기하십시오. 이 경우 반환된 벡터는 인자로 받은 contents를 참조하는 문자열 조각들이 포함되어 있어야 합니다. (query 인자가 아니라)

다른 말로 하자면, search함수로 반환되는 데이터는 search함수로 전달된 contents인자만큼 오래 유지될 것이라고 Rust에게 말해주는 겁니다. 이것이 중요합니다! 조각들에 의해 참조되는 데이터는 참조가 유효한 동안 유효해야 하기 때문이죠; 만일 컴파일러가 우리가 만든 문자열 조각이 contents에서가 아니라 query에서 만들었다고 추측하면 그에 대한 안전성 검사가 제대로 수행되지 않을 겁니다.

만약 우리가 라이프타임 어노테이션을 깜빡하고 이 함수를 컴파일하려고 시도하면, 이런 에러를 얻게 될겁니다:

error[E0106]: missing lifetime specifier
 --> src/lib.rs:5:51
  |
5 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
  |                                                   ^ expected lifetime
parameter
  |
  = help: this function's return type contains a borrowed value, but the
  signature does not say whether it is borrowed from `query` or `contents`

Rust는 두 인자 중에 우리가 필요한 쪽이 어느건지 알 수 없기 때문에, 우리가 알려줘야 합니다. contents가 우리의 문자들을 모두 가지고 있고 우리가 원하는 것은 그 중 일치하는 부분이기 때문에, contents가 라이프타임 문법을 사용하여 반환 값과 연결되어야 한다는걸 압니다.

다른 프로그래밍 언어는 인자와 반환 값을 선언부에서 연결시키라고 요구하지 않으니, 아마 이게 낯설거고, 전체적으로 좀더 쉬울겁니다. 아마 여러분은 이 예제와 10장에서 다룬 “Validating References with Lifetimes” 장의 내용을 비교하고 싶을지도 모르겠습니다.

이제 테스트를 실행해봅시다:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
--warnings--
    Finished dev [unoptimized + debuginfo] target(s) in 0.43 secs
     Running target/debug/deps/minigrep-abcabcabc

running 1 test
test test::one_result ... FAILED

failures:

---- test::one_result stdout ----
        thread 'test::one_result' panicked at 'assertion failed: `(left ==
right)`
left: `["safe, fast, productive."]`,
right: `[]`)', src/lib.rs:48:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.


failures:
    test::one_result

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--lib'

훌륭하게, 우리가 예상했던 예상대로 테스트가 실패했습니다. 테스트를 통과하게 만들어봅시다!

테스트를 통과하는 코드 작성

현재는, 우리가 늘 빈 벡터를 반환하니까 테스트가 실패하게 됩니다. 이를 수정하고 search를 구현하기 위해, 우리의 프로그램은 다음 단계를 따를 필요가 있습니다.

  • contents의 각 줄에 대한 반복작업
  • 해당 줄에 우리의 쿼리 문자열이 포함되어 있는지 검사
  • 그렇다면, 우리가 반환할 값 목록에 추가
  • 그렇지 않다면, 통과
  • 일치하는 결과 목록을 반환

각 단계를 밟아가기 위해, 줄들에 대한 반복작업부터 시작합시다!

lines 메소드를 사용하여 줄들에 대한 반복 작업

Rust는 문자열의 줄-단위로 반복 작업을 할 수 있는 유용한 메소드가 있는데, 편리하게 이름이 lines이고, 항목 12-17처럼 보여주는 것처럼 동작합니다. 아직 컴파일되지 않는다는 점에 유의하세요:

Filename: src/lib.rs

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

항목 12-17: contents의 각 줄마다 반복작업

lines 메소드는 반복자를 리턴합니다. 우리는 13장에서 반복자에 대해서 다루게 될 겁니다만, 항목 3-4에서 반복자를 사용하는 방법을 봤었다는걸 상기시켜 드립니다. 항목 3-4에서는 반복자와 함께 for반복문을 사용하여 컬렉션의 각 항목에 대해 임의의 코드를 수행했었습니다.

Query로 각 줄을 검색하기

다음으로 현재 줄에 쿼리 문자열이 포함되어 있는지 확인합니다. 다행스럽게도 문자열에는 유용한 'contains'라는 메소드가 있습니다. 항목 12-18과 같이 search 함수에서 contains 메소드에 대한 호출을 추가하십시오. 이 코드는 여전히 컴파일되지 않으니 주의하세요.

Filename: src/lib.rs

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

항목 12-18: 어느 줄이 query 문자열을 포함하고 있는지 보기 위한 기능 추가

일치하는 줄 보관하기

또한 쿼리 문자열이 포함된 줄을 저장할 방법이 필요합니다. 이를 위해 우리는 for반복문 전에 가변 벡터를 만들고 push 메소드를 호출하여 벡터에 line을 저장합니다. 항목 12-19처럼 for반복문이 끝난 다음에 벡터를 반환합니다.

Filename: src/lib.rs

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

항목 12-19: 일치하는 라인들을 저장하여 반환할 수 있게 만들기.

이제 search함수는 query를 포함하는 줄들만 반환하게 되었으니 우리의 테스트는 통과되야 할 겁니다. 테스트를 실행해 봅시다:

$ cargo test
--snip--
running 1 test
test test::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

우리 테스트가 통과되었으니, 제대로 동작한다는 것을 알게 되죠!

이 시점에서, 우리는 동일한 기능을 유지하기 위해 테스트를 통과시키면서 search 함수를 리팩토링할 기회를 고려해 볼 수 있게 됐습니다. search 함수가 많이 나쁘지는 않지만, 반복자의 기능들이 주는 유용함을 충분히 활용하지 못하고 있습니다. 우리는 13장에서 이 예제로 돌아와 반복자에 대해서 자세히 알아보고 어떻게 개선할 수 있는지 알아볼 겁니다.

run함수에서 search함수를 사용하기

Using the search Function in the run Function

이제 search 함수는 실행되고 테스트 되었지만, 우리의 run함수에서 search를 호출하게 해야 합니다. 우리는 config.query 값과 run으로 파일에서 읽어온 contentssearch함수에 전달해야 합니다. 그 이후 runsearch로부터 반환된 각 줄을 출력합니다:

Filename: src/lib.rs

pub fn run(config: Config) -> Result<(), Box<Error>> {
    let mut f = File::open(config.filename)?;

    let mut contents = String::new();
    f.read_to_string(&mut contents)?;

    for line in search(&config.query, &contents) {
        println!("{}", line);
    }

    Ok(())
}

우리는 아직 search에서 for반복문을 사용해 각 줄을 반환하고 출력하고 있습니다.

이제 우리의 프로그램 전체가 동작하는 것 같습니다! 확신하기 위해, 첫째로 “frog” 단어로 Emily Dickinson의 시에서 정확히 한 줄이 반환되야 합니다:

$ cargo run frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.38 secs
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

좋군요! 다음으 여러 줄에 일치할 “body” 같은 단어를 해봅시다:

$ cargo run body poem.txt
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/minigrep body poem.txt`
I’m nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

그리고 마지막으로, 시의 어디서도 찾을 수 없는 단어 “monomorphization” 같은걸 검색하면 어떤 줄도 찾을 수 없다는걸 확인해봅시다.

$ cargo run monomorphization poem.txt
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/minigrep monomorphization poem.txt`

훌륭해! 우리는 어플리케이션의 구조화를 어떻게 수행하는지에 대해 많은 것을 배우며 고전적인 도구를 우리 자체 미니 버전으로 만들어봤습니다. 또한 우리는 파일의 입력, 출력, 라이프타임, 테스팅과 커맨드라인 파싱에 대해서도 좀 알게 되었네요.

이 프로젝트를 완벽하게 하기 위해, 환경 변수를 다루고 표준 에러를 출력하는 방법을 간단히 시연하려고 하는데, 모두 커맨드라인 프로그램을 작성하는데 유용할 겁니다.

환경 변수들을 활용하기

우리는 추가 기능을 구현하여 minigrep을 향상시키려고 합니다. 대소문자를 구분하여 검색할지를 선택할 수 있는 기능인데, 사용자가 환경 변수를 사용하여 키고 끌 수 있게 할 수 있도록 하려 합니다. 우리는 해당 기능을 명령줄 옵션으로 구현하고 사용자가 원할때마다 해당 옵션을 기입하게 만들 수도 있지만, 대신 환경 변수를 사용하게 할 수도 있습니다. 이를 통해 사용자가 한번 환경변수를 설정하는 것을 통해 현재 터미널 세션에서 하는 모든 검색이 대소문자를 구분하게 만듭니다.

대소문자를 구분하는 search 함수의 실패 케이스 작성하기

우리는 새로운 search_case_insensitive 함수를 추가하고, 환경 변수가 적용되어 있으면 호출하고자 합니다. 우리는 TDD 절차를 따르고자 하니, 우리는 먼저 실패 테스트를 작성해야 합니다. 우리는 새 테스트를 새 search_case_insensitive를 위해 작성하고 예전에 작성한 테스트 one_resultcase_sensitive로 이름을 바꿔 두 테스트 간의 차이점을 명확하게 합니다. 항목 12-20에서는 이를 보여줍니다.

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(
            vec!["safe, fast, productive."],
            search(query, contents)
        );
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
#}

항목 12-20: 새로운 실패 테스트를 우리가 추가할 대소문자 구문 함수를 위해 추가

우리가 예전 테스트의 contents도 바꿨음을 주의하세요. 우리는 “Duct tape”라는 대문자 D로 시작되는 새로운 문자를 추가해 대소문자 구분 시에 쿼리 “duct”으로는 검색되지 않도록 하였습니다. 이러한 방식으로 이전 테스트를 변경하면 이미 구현한 대소문자 구분 검색 기능을 실수로 손상시키지 않게됩니다. 이 테스트는 지금 통과해야하며 우리가 작업을 마친 이후에도 대소문자를 구분하지 않는 검색 시에 통과되어야 합니다.

대소문자를 구분하지 않는 검색을 위해 새로 추가된 테스트는 “rUsT”를 쿼리로 사용합니다. 우리가 추가할 함수 search_case_insensitive는 “rUsT”가 대문자 R이 포함된 “Rust:”에 그리고 “Trust me.”처럼 쿼리와 다른 경우에도 일치될 겁니다. 이건 우리가 만든 search_case_insensitive 함수의 실패 테스트이고, 우리가 아직 함수를 추가하지 않았기 때문에 컴파일은 실패할 겁니다. 우리는 search` 함수를 추가할 때와 비슷한 방식으로 빈 벡터를 반환하는 뼈대를 자유롭게 추가하면 됩니다. 항목 12-16에서 테스트의 컴파일과 실패를 볼 수 있습니다.

search_case_insensitive 함수 구현하기

항목 12-21에서 보여주는 search_case_insensitivesearch 함수와 거의 같습니다. 유일하게 다른 점은 query와 각 line을 소문자로 만들어 인자의 대소문자 여부와 무관하게 동일한 문자가 각 라인에 존재하는지 검사할 수 있게 만든겁니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}
#}

항목 12-21: search_case_insensitive 함수를 정의해 query와 line을 query와 line을 비교하기 전에 소문자로 변경.

첫 째, 소문자화 한 query 문자열을 동일한 이름을 가진 그림자 변수에 보관합니다. to_lowercase를 쿼리에서 호출하면 사용자의 쿼리가 “rust”, “RUST”, “Rust”, 혹은 “rUsT”인지 구분할 필요가 없어지고, 우리는 사용자 쿼리가 “rust” 로 간주하고 대소문자 구문을 하지 않을 겁니다.

to_lowercase 호출은 기존 데이터를 참조하는 것이 아니라 새로운 데이터를 생성기 때문에 query는 문자열 슬라이스가 아닌 String입니다. 예로 들었던 쿼리 “rUsT” 문자열 slice에는 우리가 사용할 “u” 또는 “t” 소문자가 없으므로 “rust”가 포함 된 새 String을 할당해야 합니다. 우리가 contains 메소드에 인자로 query를 전달할 때 contains의 선언이 문자열 slice를 인자로 받게 정의되어 있으니 앰퍼샌드(&)를 추가해야합니다.

다음으로, 우리는 각 line에 모두 소문자로 이뤄진 query가 존재하는지 검사하기 전에 to_lowercase를 호출합니다. 이제 linequery를 모두 소문자로 변경했으니, 대소문자 구분없이 매치되는 문자열을 검색할 수 있습니다.

해당 구현이 테스트들을 통과하는지 한번 보시죠.

running 2 tests
test test::case_insensitive ... ok
test test::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

시원하게 통과했습니다. 이제 run 함수에서 신상 search_case_insensitive를 호출해보자구요. 먼저 Config 구조체에 검색을 시에 대소문자를 구분할지 설정 옵션을 추가부터 하구요. 근데 이 필드를 추가하면 컴파일러가 필드 값을 초기화 하지 않았다고 에러를 내게 되요.

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}
#}

우리는 불린 값을 갖는 case_sensitive를 추가했어요. 다음으로, 우리는 run 함수를 실행해서 case_sensitive 필드의 값을 확인한 뒤에 search 함수와 search_case_insensitive 함수 중에 어느 쪽을 호출 할 것인지 결정하면 되요, 항목 12-22처럼 말이죠. 아직도 컴파일은 안되욧!

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
# use std::error::Error;
# use std::fs::File;
# use std::io::prelude::*;
#
# fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
#      vec![]
# }
#
# fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
#      vec![]
# }
#
# struct Config {
#     query: String,
#     filename: String,
#     case_sensitive: bool,
# }
#
pub fn run(config: Config) -> Result<(), Box<Error>> {
    let mut f = File::open(config.filename)?;

    let mut contents = String::new();
    f.read_to_string(&mut contents)?;

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

    for line in results {
        println!("{}", line);
    }

    Ok(())
}
#}

항목 12-22: config.case_sensitive의 값을 기준으로
search 혹은 search_case_insensitive이 호출됩니다.

마지막으로, 우리는 환경 변수를 검사해야 해요. 환경 변수를 다루기 위한 함수들은 env모듈이 있는 표준 라이브러리에 있어요, 그래서 우리는 use std::env;src/lib.rs의 최상단에 추가해서 현재 범위로 끌어오려고 해요. 그러면 우리는 env에 있는 var메소드를 사용하여 CASE_INSENSITIVE란 이름의 환경변수를 검사할 수 있죠. 항목 12-23에서 보이듯 말이에요.

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
use std::env;
# struct Config {
#     query: String,
#     filename: String,
#     case_sensitive: bool,
# }

// --snip--

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config { query, filename, case_sensitive })
    }
}
#}

항목 12-23: CASE_INSENSITIVE란 이름의 환경변수 검사하기

여기서 우리는 case_sensitive라는 새 변수를 만들어요. 그의 값을 설정하려고, env::var 함수를 호출하고 CASE_INSENSITIVE란 환경변수의 이름을 전달하죠. env::var 메소드는 Result를 반환하는데, 만약 환경변수가 설정된 상태라면 환경 변수의 값을 포함한 성공한 Ok 변형체가, 만약 설정되지 않았다면 Err 변형체를 반환하게 됩니다.

우리는 Resultis_err 메소드를 에러이며 설정되지 않은 상태라서 대소문자를 구분하는 검색을 해야하는지 확인하고자 사용합니다. 만약 CASE_INSENSITIVE 환경 변수에 뭐라도 설정이 되었으면, is_err는 false를 반환하고 대소문자 구분 검색을 수행하게 될겁니다. 우리는 환경변수의 내용은 신경쓰지 않고, 그저 그게 설정이 되어있는지만을, is_err로 검사하며 unwrap, expectResult에 존재하는 다른 메소드는 사용하지 않았어요.

항목 12-22에서 구현했던 것처럼 case_sensitive 변수의 값을 Config 인스턴스에 전달하여 run 함수가 해당 값을 읽고 search_case_insensitive 또는 search 를 호출할지 여부를 결정할 수 있도록 합니다.

이제 돌려보죠! 처음에는 프로그램을 환경변수 설정없이 “to” 쿼리와 함께 실행하면, 소문자 “to” 를 포함하는 모든 줄이 일치되게 됩니다.

$ cargo run to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!

잘 동작하고 있네요! 이제, 프로그램을 CASE_INSENSITIVE1로 설정하지만 쿼리는 동일한 “to”로 실행해볼까요.

PowerShell을 사용하는 경우 환경 변수를 설정하고 둘로 나눈 명령으로 프로그램을 실행해야합니다.

$ $env:CASE_INSENSITIVE=1
$ cargo run to poem.txt

대소문자 “to” 가 포함된 줄을 가져와야 합니다.

$ CASE_INSENSITIVE=1 cargo run to poem.txt
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

훌륭하게, “To”가 포함 된 줄도 있습니다! 우리의 minigrep 프로그램은 이제 환경변수를 통해 대소문자를 구분하지 않고 검색 할 수 있습니다. 이제 커맨드라인 인수나 환경변수를 사용하여 설정 옵션을 관리하는 방법을 알게 되었네요!

일부 프로그램은 동일 설정에 대해 인수, 그리고 환경변수를 모두 허용합니다. 이 경우 프로그램은 둘 중 하나의 우선 순위를 결정합니다. 또다른 독자 연습의 일환으로, 커맨드라인 인수와 환경변수를 통해 대소문자 구분을 제어 해보세요. 프로그램이 하나는 대소문자를 구분하고 다른 하나는 구분하지 않도록 설정되어 실행된다면 커맨드라인 인자와 환경변수 중에 어느쪽에 우선순위를 둘지 결정해보세요.

std::env 모듈에는 환경 변수를 다루는 데 유용한 여러 가지 기능이 있으니 사용 가능한 내용을 보려면 문서를 확인하세요.

표준출력 대신 표준에러로 에러메시지 출력하기

지금까지 우리는 모든 출력을 println!을 사용하여 터미널에 출력했습니다. 대부분의 터미널은 두 가지 방식의 출력 을 지원합니다: 표준 출력 (stdout)은 일반적인 정보전달용이고 표준 에러 (stderr)는 에러 메시지용 입니다. 이렇게 구분지음으로 인해 사용자는 프로그램의 출력을 직접 파일에 작성하면서도 여전히 에러메시지를 화면에 출력할 수 있습니다.

println! 함수는 오직 표준출력만 사용할 수 있으므로, 우리는 표준에러에 출력을 위한 다른 것을 알아보겠습니다.

에러가 어디에 출력될지 검사

먼저, minigrep의 출력 내용이 어떻게 표준출력에 작성되는지를 후에 우리가 표준에러로 바꾸려는 에러메시지를 염두하며 살펴봅시다. 에러가 발생할 것을 인지한채로 우리는 표준출력 스트림을 파일로 변경하고자 합니다. 표준에러 스트림은 변경하지 않을 것이므로, 표준에러로 보내진 모든 출력내용은 화면에 표시될 겁니다.

커맨드라인 프로그램들은 에러메시지들이 표준에러로 전달되는 것을 상정하고 있기 때문에 표준출력 스트림을 파일로 변경하더라도 우리는 에러메시지가 출력되는 것을 여전히 볼 수 있습니다. 우리 프로그램은 정상 동작하고 있지 않습니다 : 오류메시지 출력이 파일로 저장되고 있거든요!

이런 동작을 시연하는 방법은 프로그램의 실행시킬때 >과 표준출력 스트림을 향하게 할 파일이름을 주면 됩니다. 에러가 발생할 여지가 있는 인자는 주지 않습니다.

$ cargo run > output.txt

> 문법은 쉘에게 표준출력의 내용을 화면이 아닌 *output.txt`에 출력하게끔 하는 것입니다. 우리가 기대했던 에러에시지의 화면 출력은 보지 못했으니 이것은 파일 마지막에 기록됐을 겁니다. 다음인 output.txt의 내용입 니다.

Problem parsing arguments: not enough arguments

역시, 우리의 에러메시지는 표준출력으로 출력되었네요. 이런 에러메시지가 표준에러로 출력된다면 훨씬 유용하고 우리가 같은 방법으로 표준출력을 변경했을때 오직 성공적 실행에 관련된 데이터만 저장할 수 있게 될 겁니다. 지금 바꿔봅시다.

에러를 표준에러로 출력하기

우리는 항목 12-24의 코드를 출력되는 에러메시지들을 변경하는데 사용하고자 합니다. 이번 장 진입부에서 리팩토링한 결과 모든 에러메시지는 하나의 함수 main에서 출력되고 있습니다. 표준라이브러리에 존재하는 표준에러에 출력해주는 매크로 eprintln!를 사용하여 println!을 사용하여 에러를 출력하던 두 부분을 eprintln!`을 사용하도록 변경 해봅시다.

Filename: src/main.rs

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {}", e);

        process::exit(1);
    }
}

항목 12-24: 표준출력에 에러메시지를 출력하던 것을 eprintln!을 사용하여 표준에러로 변경하기

println!eprintln!으로 변경한 후에, 같은 방식으로 >을 사용해 표준출력을 변경하는 것 외에 다른 인자를 주지 않고 프로그램을 다시 실행시켜 봅시다.

$ cargo run > output.txt
Problem parsing arguments: not enough arguments

이제 우리는 에러를 화면에서 볼 수 있고, 우리가 커맨드라인 프로그램에서 기대한 대로 output.txt는 비어있습니다.

이번에는 에러를 발생시키지 않게 인자와 함께 프로그램을 실행시키면서 표준출력을 파일로 변경해봅시다.

$ cargo run to poem.txt > output.txt

터미널에는 아무것도 출력되지 않고, output.txt가 보관하게 됩니다.

Filename: output.txt

Are you nobody, too?
How dreary to be somebody!

이번 시연은 우리가 표준출력에 성공적출력을 표준에러에 에러출력을 의도한 대로 수행하고 있음을 보여줍니다.

종합

이번 장에서는 지금까지 우리가 배웠던 몇 가지 주요 개념을 되짚어보고 Rust 문법에서 범용 I/O 작업수행을 하는 방법을 알아봤습니다. 커맨드라인 인자, 파일, 환경변수, 그리고 eprintln!매크로로 에러출력를 사용하여 당신은 이제 커맨드라인 응용프로그램을 작성할 준비가 됐습니다. 이전 장들의 개념을 활용하여, 당신의 코드는 잘 구조화되고, 적합한 데이터 구조를 사용하여 효율적으로 데이터를 저장하며, 에러를 보기좋게 관리하며, 잘 테스트 할 수 있게 됐습니다.

다음으로, 우리는 함수형 언어의 영향을 받은 Rust의 기능 몇가지를 알아보겠습니다 : 클로저와 반복자.

함수형 언어의 특성들: 반복자들과 클로저들

러스트의 디자인은 많은 기존 언어들과 기술들에서 영감을 얻었으며, 중요한 영향 중에 하나는 함수형 프로그래밍 입니다. 함수형 스타일의 프로그래밍은 자주 함수를 값처럼 인자로 넘기는 것, 다른 함수들에 서 결괏값으로 함수들을 돌려주는 것, 나중에 실행하기 위해 함수를 변수에 할당하는 것 등을 포함합니다. 이번 장에서는, 무엇이 함수형 프로그래밍이고 그렇지 않은지에 대해 논의하는 대신, 다른 언어에서 자주 함수형으로 언급되는 특성들과 유사한 러스트의 특성들에 대해 논의할 것입니다.

더 구체적으로, 이것들을 다룹니다:

  • 클로저들, 변수에 저장할 수 있는 함수와 유사한 구조.
  • 반복자들, 일련의 요소들을 처리할 수 있는 방법.
  • 이 두가지 특성들을 사용해서 12장의 I/O 프로젝트를 향샹시킬 수 있는 방법.
  • 이 두 특성들의 성능 (스포일러 있음: 생각보다 빠릅니다!)

다른 장에서 다룬 패턴 매칭이나 열거형과 같은 다른 러스트의 특성들도 역시 함수형 스타일의 영향을 받았습니다. 클로저들과 반복자들을 정복하는 것은 자연스러우면서도 빠른 러스트 코드를 작성하는데 중요한 부분 입니다, 그래서 이번 장 전체에서 이것들을 다룹니다.

클로저: 환경을 캡처할 수 있는 익명 함수

러스트의 클로저는 변수에 저장하거나 다른 함수에 인자로 넘길 수 있는 익명 함수입니다. 한 곳에서 클로저를 만들고 다른 문맥에서 그것을 평가하기 위해 호출할 수 있습니다. 함수와 다르게 클로저는 그들이 호출되는 스코프로부터 변수들을 캡처할 수 있습니다. 이 클로저 특성이 코드 재사용과 동작 사용자 정의를 어떤 식으로 허용하는지 예를 들어 보여줄 것입니다.

클로저로 행위를 추상화 하기

클로저를 나중에 실행하기 위해 저장하는 것이 유용한 상황에 대한 예제로 작업해 봅시다. 따라가다 보면, 클로저 문법과 타입 추론, 트레잇에 대해 이야기할 것입니다.

이런 가상의 상황을 생각해 봅시다: 우리는 맞춤 운동 계획을 생성하는 앱을 만드는 스타트업에서 일합니다. 백엔드는 러스트로 작성되어 있고, 운동 계획을 생성하는 알고리즘은 앱 사용자의 나이, 체질량 지소, 선호도, 최근 운동들과 그들이 지정한 강도 숫자와 같은 많은 다른 요소들을 고려합니다. 이 예제에서 사용되는 실제 알고리즘은 중요하지 않습니다; 중요한 것은 이 알고리즘이 몇 초가 걸린다는 것입니다. 이 알고리즘을 우리가 필요할 때 한 번만 호출하기를 원하고, 그래서 사용 자가 필요 이상으로 기다리지 않게 만들고 싶습니다.

우리는 리스트 13-1 에 보이는 simulated_expensive_calculation 함수를 사용해서 이 가상의 알고리즘 호출을 실험할 것입니다. 이 함수는 calculating slowly... 을 출력하고, 2초를 기다린 다음, 인자로 넘어온 어떤 값이든 돌려줍니다:

파일명: src/main.rs


# #![allow(unused_variables)]
#fn main() {
use std::thread;
use std::time::Duration;

fn simulated_expensive_calculation(intensity: u32) -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    intensity
}
#}

리스트 13-1: 실행시간이 2초 걸리는 가상의 계산을 대신하는 함수

다음은 이 예제에서 중요한 운동 앱의 일부를 담고 있는 main 함수 입니다. 이 함수는 사용자가 운동 계획을 물어볼 때 앱이 호출 할 코드를 나타냅니다. 앱의 프론트엔드와의 상호작용은 클로저를 사용하기에 적합하지 않기 때문에, 우리 프로 그램에 대한 입력을 나타내는 값을 코드상에 넣어두고 결과를 출력 할 것 입니다.

필요한 입력들은:

  • 사용자로 부터의 강도 숫자, 이것은 그들이 운동을 요청할 때 지정되며, 낮은 강도 운동을 원하는지 혹은 고강도 운동을 원하는지를 나타냅니다.
  • 임의의 숫자는 몇 가지 다양한 운동 계획들을 생성할 것입니다.

결과는 추천 운동 계획이 될 것입니다. 리스트 13-2 에 우리가 사용할 main 함수 가 있습니다:

파일이름: src/main.rs

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(
        simulated_user_specified_value,
        simulated_random_number
    );
}
# fn generate_workout(intensity: u32, random_number: u32) {}

리스트 13-2:사용자 입력과 임의의 숫자 생성을 시뮬레이션 하기 위한 main 함수와 하드코딩된 값

단순함을 위해서 simulated_user_specified_value 변수의 값을 10 으로하고 simulated_random_number 변수의 값을 7로 하드코딩 했습니다; 실제 프로그램에서, 강도 숫자를 앱 프론트엔드에서 얻고 2장의 추리게임에서 그랬던 것 처럼, 임의의 숫자 생성을 위해 rand 크레이트를 사용합니다. main 함수는 generate_workout 함수를 모의의 입력값으로 호출 합니다.

이제 상황이 만들어 졌으니, 알고리즘으로 넘어가겠습니다. 리스트 13-3 에 있는 generate_workout 함수는 이 예제에서 가장 신경써야 할 앱의 비즈니스 로직을 포함하고 있습니다. 이 예제에서 나머지 코드를 변경 사항은 이 함수에 적용 됩니다:

파일이름: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# use std::thread;
# use std::time::Duration;
#
# fn simulated_expensive_calculation(num: u32) -> u32 {
#     println!("calculating slowly...");
#     thread::sleep(Duration::from_secs(2));
#     num
# }
#
fn generate_workout(intensity: u32, random_number: u32) {
    if intensity < 25 {
        println!(
            "Today, do {} pushups!",
            simulated_expensive_calculation(intensity)
        );
        println!(
            "Next, do {} situps!",
            simulated_expensive_calculation(intensity)
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                simulated_expensive_calculation(intensity)
            );
        }
    }
}
#}

리스트 13-3: 입력값과 simulated_expensive_calculation 함 수 호출에 근거해서 운동 계획을 출력하는 비즈니스 로직

리스트 13-3 의 코드는 느린 계산 함수에 대해 여려번 호출을 합니다. 첫번째 if 블럭은 simulated_expensive_calculation 함수를 두번 호출하고, 바깥 else 의 안쪽에 있는 if 문에서는 전혀 호출하지 않으며, 두번째 else 문 의 경우는 한번 호출 합니다.

generate_workout 함수의 바람직한 행위는 먼저 사용자가 저강도 운동(25보다 작은 수로 표시) 혹은 고강도 운동(25 혹은 더 큰수)을 원하는지 체크하는 것입니다.

저강도 운동 계획은 우리가 시뮬레이션 하는 복잡한 알고리즘에 근거에서 푸쉬업과 싯업의 수를 추천 할 것입니다.

사용자가 고강도 운동을 원한다면, 약간의 추가 로직이 있습니다: 앱에 의해 생성된 임의의 숫자가 3이면, 앱은 휴식과 수분 섭취를 추천합니다. 그렇지 않다면, 사용자는 복잡한 알고리즘을 기반으로 몇 분의 달리기를 안내 받을 것입니다.

데이터 과학팀은 앞으로 알고리즘 호출 방식을 일부 변경해야 한다고 알렸습니다. 이러한 변경이 발생 했을 때 업데이트를 단순화 하기 위해서, 이 코드를 리팩토링 하여 simulated_expensive_calculation 함수를 단지 한 번만 호출 하도록 하려고 합니다. 또한 현재 프로세스에서 해당 함수에 대한 다른 호출을 추가하지 않고 불필요하게 함수를 두 번 호출하는 위치 없애고 싶습니다. 즉, 결과가 필요없다면 함수를 호출하고 싶지 않고, 여전히 그것을 한 번만 호출하고 싶습니다.

함수를 사용해서 리팩토링 하기

우리는 여러 방향으로 운동 프로그램을 다시 구조화 할 수 있습니다. 우선, 리스트 13-4 에 보이는 것처럼, 중복된 expensive_calculation 함수 호출을 하나의 변수로 추출 해볼 것입니다:

파일이름: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# use std::thread;
# use std::time::Duration;
#
# fn simulated_expensive_calculation(num: u32) -> u32 {
#     println!("calculating slowly...");
#     thread::sleep(Duration::from_secs(2));
#     num
# }
#
fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_result =
        simulated_expensive_calculation(intensity);

    if intensity < 25 {
        println!(
            "Today, do {} pushups!",
            expensive_result
        );
        println!(
            "Next, do {} situps!",
            expensive_result
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_result
            );
        }
    }
}
#}

리스트 13-4: simulated_expensive_calculation 에 대한 호출들을 한 곳으로 추출하고 결과를 expensive_result 변수에 저장하기.

이 변경은 simulated_expensive_calculation 에 대한 모든 호출들을 하나로 합치고 첫번째 if 문에서 불필요하게 이 함수를 여러번 호출하던 문제를 해결 합니다. 불행하게도, 이제 모든 경우에 대해서 이 함수를 호출하고 결과를 기다리며, 이 결과를 전혀 사용하지 않는 안쪽 if 블럭도 해당됩니다.

우리는 프로그램에서 한곳에서 코드를 정의하고, 실제로 결과가 필요한 곳에서만 그 코드를 실행하고 싶습니다. 이것이 클로저의 유스 케이스 입니다.

코드를 저장하기 위해 클로저를 사용해서 리팩토링 하기.

if 블럭 전에 항상 simulated_expensive_calculation 함수를 호출하는 대신, 리스트 13-5에 보이는 것 처럼, 클로저를 정의하고 변수에 결과를 저장하기 보단 클로저를 변수에 저장 할 수 있습니다. 여기서 소개하는 것처럼 실제로 클로저 안에 simulated_expensive_calculation 의 전체 내용을 옮길 수 있습니다.

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# use std::thread;
# use std::time::Duration;
#
let expensive_closure = |num| {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    num
};
# expensive_closure(5);
#}

리스트 13-5: 클로저를 정의하고 expensive_closure 변수에 저장하기

클로저 정의는 변수 expensive_closure 에 그것을 할당하기 위해 = 다음에 옵니다. 클로저를 정의하기 위해, 수직의 파이프 (|) 한쌍으로 시작하며, 그 사이에 클로저에 대한 파라미터를 기술합니다; 이 문법은 스몰토크와 루비에서 클로저 정의와의 유사성 때문에 선택 되었습니다. 이 클로저는 num 이라는 하나의 파라미터를 갖습니다: 하나 이상의 파라미터를 갖는다면, |param1, param2| 와 같이 콤마로 구분합니다.

파라미터들 다음에, 클로저의 바디를 포함하는 중괄호를 넣습니다—클로저 바디가 하나의 표현식이라면 이것은 선택적 입니다. 중괄호 다음에 클로저의 끝에는 let 문을 완성하기 위해 세미콜론이 필요합니다. 클로저 바디에서 마지막 줄로부터 반환되는 값인 (num) 은 그것이 호출되었을 때 클로저로 부터 반환되는 값이 될 것입니다, 왜냐하면 그 줄은 함수 본문 처럼 세미콜론으로 끝나지 않기 때문 입니다.

let 문은 expensive_closure 가 익명함수의 정의를 포함하며, 익명함수를 호출한 결과 값을 포함하지 않는다는 것에 유의 하세요. 우리가 클로저를 사용하는 이유는 호출할 코드를 한 곳에서 정의하고, 그 코드를 저장하며, 이후 다른 곳에서 그것을 호출하길 원하기 때문이라는 것을 상기하세요; 우리가 호출하고자 하는 코드가 이제 expensive_closure 에 저장되었습니다.

클로저를 정의하면서, 저장된 코드를 실행하고 결과값을 얻기 위하여 if 블록 안의 코드를 클로저 호출 방식으로 변경할 수 있습니다. 우리는 함수를 호출하는 것 처럼 클로저를 호출 합니다: 리스트 13-6 에 보이는 것처럼, 클로저 정의를 갖고 있는 변수명을 쓰고 다음엔 사용할 인자값을 포함하는 괄호가 따라 옵니다:

파일명: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# use std::thread;
# use std::time::Duration;
#
fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num| {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!(
            "Today, do {} pushups!",
            expensive_closure(intensity)
        );
        println!(
            "Next, do {} situps!",
            expensive_closure(intensity)
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_closure(intensity)
            );
        }
    }
}
#}

리스트 13-6: 우리가 정의한 expensive_closure 호출하기

이제 비용이 큰 계산은 단 한곳에서만 호출 되고, 우리가 결과가 필요한 곳에서만 그 코드를 실행 합니다.

그러나, 리스트 13-3 에 있는 문제중 하나를 다시 소개합니다: 우리는 여전히 첫번째 if 블럭에서 클로저를 두번 호출 하는데, 이는 비용이 큰 코드를 두번 호출하고 사용자가 실행시간 만큼 긴시간을 두번 기다리게 합니다. 우리는 그 if 블럭안에 클로저 호출의 결과를 저장하는 로컬 변수를 만들어서 그 문제를 해결할 수 있지만, 클로저는 다른 해결책을 제공합니다. 우리는 그 해결책에 대해 조금 이야기할 것입니다. 그러나 우선 클로저 정의에 타입 어노테이션이 없는 이유와 클로저와 연관된 트레잇에 대해 이야기 합시다.

클로저 타입 추론과 어노테이션

클로저는 fn 함수처럼 파라미터나 반환값의 타입을 명시할 것을 요구하지 않습니다. 타입 어노테이션은 사용자에게 노출되는 명시적인 인터페이스의 일부이기 때문에 함수에 필요 합니다. 이 인터페이스를 엄격하게 정의하는 것은 함수가 어떤 타입의 값을 사용하고 반환하는지에 대해 모두가 합의 한다는 것을 보장하는데 중요 합니다. 그러나 클로저는 이와 같이 노출된 인터페이스에 사용되지 않습니다: 변수에 저장되고 이름없이 우리의 라이브러리 사용자들에게 노출되지 않고 사용 됩니다.

추가적으로, 클로저는 보통 짧고 임의의 시나리오 보다 좁은 문맥 안에서만 관련이 있습니다. 이런 제한된 문맥 안에서만, 컴파일러는 안정적으로 파라미터와 리턴타입을 추론할 수 있으며, 이는 대부분의 변수 타입을 추론 할 수 있는 방법과 비슷 합니다.

프로그래머들에게 이런 작고 익명의 함수들에 타입을 달도록하는 것은 짜증나고 컴파일러가 이미 사용할수 있는 정보와 대게는 중복 됩니다.

변수처럼, 엄밀하게 필요한 것 이상으로 자세히 표현하는 비용을 지불하고서라도 명확성과 명료성을 높이고 싶다면 타입 어노테이션(혹은 타입 명시)를 추가할 수 있습니다; 리스트 13-4 에 정의한 클로저에 타입을 명시하는 것은 리스트 13-7 에 보이는 것과 같을 것입니다:

파일명: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# use std::thread;
# use std::time::Duration;
#
let expensive_closure = |num: u32| -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    num
};
#}

리스트 13-7: 클로저에 파라미터와 반환값 타입에 대한 선택적 인 타입 어노테이션 추가하기

타입 어노테이션이 있으면 클로저와 함수의 문법은 더 비슷해 보입니다. 다음은 파라미터에 1을 더하는 함수 정의와 동일한 행위를 하는 클로저를 수직으로 비교한 것입니다. 관련 있는 부분들을 정렬하기 이해 약간의 공백을 추가했습니다. 이것은 파이프를 사용하는 것과 선택적인 문법의 양을 제외하고 클로저 문법과 함수 문법이 얼마나 비슷한지 보여줍니다:

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

첫번째 줄은 함수 정의를 보여주고, 두번째 줄은 타입을 모두 명기한 클로저 정의를 보여 줍니다. 세번째 줄은 클로저 정의에서 타입 어노테이션을 지웠고, 네번째 줄은 선택적인 중괄호를 지웠는데, 클로저 보디가 단 하나의 표현식을 갖기 때문 입니다. 이것은 모두 호출 했을 때 동일한 행위를 수행하는 유효한 정의들 입니다.

클로저 정의는 각 파리미터들과 그들의 반환값에 대해 단 하나의 추론된 구체적인 타입을 갖을 것입니다. 예를 들면, 리스트 13-8 은 파리미터로 받은 값을 그대로 반환하는 짧은 클로저의 정의를 보여줍니다. 이 클로저는 이 예제의 목적 이에외는 유용하지 않습니다. 정의에 타입 어노테이션을 추가하지 않았다는 것에 유의하세요: 클로저를 두번 호출하는데, 첫번째는 String 을 인자로 사용하고 두번째는 u32 을 사용한다면 에러가 발생합니다:

파일명: src/main.rs

let example_closure = |x| x;

let s = example_closure(String::from("hello"));
let n = example_closure(5);

리스트 13-8: 두개의 다른 타입으로 추론된 타입을 갖는 클로저 호출 해보기

컴파일러는 이런 에러를 줍니다:

error[E0308]: mismatched types
 --> src/main.rs
  |
  | let n = example_closure(5);
  |                         ^ expected struct `std::string::String`, found
  integral variable
  |
  = note: expected type `std::string::String`
             found type `{integer}`

처음 String 값으로 example_closure 을 호출하면, 컴파일러는 x 의 타입과 클로저의 반환 타입을 String 으로 추론합니다. 이 타입들은 그다음에는 example_closure 에 있는 클로저에 고정되고, 같은 클로저를 다른 타입으로 사용하려고 할 때 타입 에러를 얻게 됩니다.

제너릭 파라미터와 Fn 트레잇을 사용하여 클로저 저장하기

운동 생성 앱으로 돌아갑시다. 리스트 13-6 에서, 우리의 코드는 아직도 비용이 큰 계산을 하는 클로저를 필요한 것 보다 더 많이 호출 합니다. 이 문제를 풀기위한 한가지 옵션은 비싼 비용의 클로저 결과를 재활용을 위해 변수에 저장하고 결과가 필요한 부분에서 클로저를 다시 호출하는 대신 그 변수를 사용하는 것입니다. 그러나, 이 방법은 많은 반복된 코드를 만들 수 있습니다.

운 좋게도, 다른 해결책이 있습니다. 우리는 클로저와 클로저를 호출한 결과값을 갖고 있는 구조체를 만들 수 있습니다. 그 구조체는 결과값을 필요로 할 때만 클로저를 호출 할 것이며, 결과값을 캐시에 저장해 두어 우리의 나머지 코드에서 결과를 저장하고 재사용 하지 않아도 되도록 할 것입니다. 이 패턴을 메모이제이션(memoization) 혹은 *지연 평가(lazy evaluation)*로 알고 있을 것 입니다.

구조체에서 클로저를 갖고 있도록 하기 위해, 클로저 타입을 기술 할 필요가 있는데, 구조체 정의는 각 필드의 타입을 알 필요가 있기 때문 입니다. 각 클로저 인스턴스는 자신의 유일한 익명 타입을 갖습니다: 즉, 두 클로저가 동일한 타입 서명을 갖더라도 그들의 타입은 여전히 다른 것으로 간주 됩니다. 클로저를 사용하는 구조체, 열거형, 함수 파라미터를 정의하기 위해, 10장에서 설명한 것 처럼 제네릭과 트레잇 바운드를 사용합니다.

Fn 트레잇은 표준 라이브러리에서 제공 합니다. 모든 클로저들은 다음 트레잇 중 하나를 구현 합니다: Fn, FnMut, 혹은 FnOnce. 환경을 캡처하는 것에 대한 다음 절에서 이 트레잇들의 차이점들에 대해 설명할 것입니다; 이 예제에서, Fn 트레잇 을 사용할 수 있습니다.

클로저가 이 트레잇 바운드에 맞춰야 하는 파라미터와 반환값의 타입을 표현하기 위해 Fn 트레잇 바운드에 타입을 추가 합니다. 이 경우, 클로저는 파라미터 타입이 u32 이고 u32 타입을 번환하므로, 명시하는 트레잇 바운드는 Fn(u32) -> u32 입니다.

리스트 13-9 는 Cacher 구조체의 정의를 보여주는데 클로저와 선택적인 반환값을 갖고 있습니다:

파일명: src/main.rs


# #![allow(unused_variables)]
#fn main() {
struct Cacher<T>
    where T: Fn(u32) -> u32
{
    calculation: T,
    value: Option<u32>,
}
#}

리스트 13-9: calculation 에 클로저를 담고, 선택적인 결과 를 value 에 담는 Cacher 구조체 정의하기

Cacher 구조체는 제너릭 타입 Tcalculation 필드를 갖습니다. T 에 대한 트레잇 바운드는 Fn 트레잇을 사용하여 그것이 클로저라는 것을 기술 합니다. calculation 필드에 저장하고자 하는 클로저는 하나의 u32 타입 파라미터 (Fn 다음에 괄호안에 명시됨)를 갖고 u32 (-> 다음에 명시됨) 타입의 값을 반환해야 합니다.

노트: 함수는 세개의 Fn 트레잇도 모두 구현 합니다. 환경에서 값을 캡처할 필요 가 없다면, Fn 트레잇을 구현한 어떤것을 필요로 하는 곳에 클로저 대신 함수를 사용할 수 있습니다.

value 필드는 Option<u32> 타입 입니다. 클로저를 실행하기 전에는 valueNone 일 것입니다. Cacher 를 사용하는 코드에서 클로저의 결과 를 요청할 경 우, Cacher 는 그 때 클로저를 실행하고 결과를 Some variant 에 넣어서 value 필드에 저장 할 것입니다. 그 다음에는 코드에서 클로저의 결과를 다시 요청하면 클로저를 다시 실행하는 대신, CacherSome variant 안에 있는 결과를 돌려줄 것입니다.

방금 설명한 value 필드에 대한 로직은 리스트 13-10 에 정의되어 있습니다:

파일명: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# struct Cacher<T>
#     where T: Fn(u32) -> u32
# {
#     calculation: T,
#     value: Option<u32>,
# }
#
impl<T> Cacher<T>
    where T: Fn(u32) -> u32
{
    fn new(calculation: T) -> Cacher<T> {
        Cacher {
            calculation,
            value: None,
        }
    }

    fn value(&mut self, arg: u32) -> u32 {
        match self.value {
            Some(v) => v,
            None => {
                let v = (self.calculation)(arg);
                self.value = Some(v);
                v
            },
        }
    }
}
#}

리스트 13-10: Cacher 의 캐싱 로직

우리는 이 필드에 있는 값을 호출하는 코드에서 잠재적으로 변경하도록 두기 보다 Cacher 가 구조체 필드의 값을 관리하도록 하고 싶기 때문에, 이 필드는 비공개 (private) 입니다.

Cacher::new 함수는 제네릭 파라미터 T 를 받는데, Cacher 구조체와 동일한 트레잇 바운드를 갖도록 정의 되었습니다. 그 다음 Cacher::newcalculation 필드에 명시된 클로저를 포함하고 클로저를 아직 실행한적이 없기 때문에 value 필드가 None 값을 갖는 Cacher 인스턴스를 반환 합니다.

호출하는 코드에서 클로저를 평가한 결과값을 원할때, 클로저를 직접 호출하기 보다, value 메서드를 호출 할 것입니다. 이 메서드는 이미 self.value 에 결과값을 Some 으로 갖고 있는지 체크 합니다; 만약 그렇다면 클로저를 다시 실행하는 대신 Some 안에 있는 값을 반환 합니다.

만약 self.valueNone 이라면, self.calculation 에 저장된 클로저를 호출 하고, 나중에 재사용 하기 위해 결과를 self.value 저장한 다음 그 값을 반환 합니다.

리스트 13-11 는 리스트 13-6 에 있는 generate_workout 함수에서 이 Cacher 구조 체를 사용하는 방법을 보여줍니다:

파일명: src/main.rs


# #![allow(unused_variables)]
#fn main() {
# use std::thread;
# use std::time::Duration;
#
# struct Cacher<T>
#     where T: Fn(u32) -> u32
# {
#     calculation: T,
#     value: Option<u32>,
# }
#
# impl<T> Cacher<T>
#     where T: Fn(u32) -> u32
# {
#     fn new(calculation: T) -> Cacher<T> {
#         Cacher {
#             calculation,
#             value: None,
#         }
#     }
#
#     fn value(&mut self, arg: u32) -> u32 {
#         match self.value {
#             Some(v) => v,
#             None => {
#                 let v = (self.calculation)(arg);
#                 self.value = Some(v);
#                 v
#             },
#         }
#     }
# }
#
fn generate_workout(intensity: u32, random_number: u32) {
    let mut expensive_result = Cacher::new(|num| {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    });

    if intensity < 25 {
        println!(
            "Today, do {} pushups!",
            expensive_result.value(intensity)
        );
        println!(
            "Next, do {} situps!",
            expensive_result.value(intensity)
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_result.value(intensity)
            );
        }
    }
}
#}

리스트 13-11: 캐싱 로직을 추상화 하기 위해 generate_workout 함수 안에서 Cacher 사용하기

클로저를 변수에 직접 저장하는 대신, 클로저를 갖는 Cacher 의 새 인스턴스를 저장 했습니다. 그러고는, 결과가 필요한 각 위치에 Cacher 인스턴스의 value 메소드를 호출 했습니다. 우리는 value 메소드를 원하는 만큼 많이 호출할 수 있고, 전혀 호출하지 않을 수도 있으며, 비싼 비용의 게산은 최대 한 번만 수행 될 것입니다.

리스트 13-2 의 main 함수로 이 프로그램을 실행해 보세요. 다양한 ifelse 블럭에 있는 모든 케이스들을 검증하기 위해 simulated_user_specified_valuesimulated_random_number 변수들을 변경해 보면, calculating slowly... 메세지는 필요할 때 단지 한 번만 나타 납니다. Cacher 는 필요한것 보다 더 많이 비싼 비용의 계산을 호출하지 않도록 보장하는 필요한 로직을 처리해서, generate_workout 가 비즈니스 로직에 집중하도록 해줍니다.

Cacher 구현의 제약사항

값을 캐싱하는 것은 일반적으로 유용한 동작이기 때문에 이와는 다른 클로저를 사용 해서 우리 코드의 다른 부분에서 적용하고 싶을 수도 있습니다. 그러나 현재 Cacher 구현은 다른 문맥에서 다르게 재사용 하기에는 두 가지 문제가 있습니다.

첫 번째 문제는 Cacher 인스턴스가 value 메소드의 arg 파라미터에 대해 항상 같은 값을 얻는다는 가정을 한다는 것입니다. 즉, 이 Cacher 테스트는 실패 할 것 입니다:

#[test]
fn call_with_different_values() {
    let mut c = Cacher::new(|a| a);

    let v1 = c.value(1);
    let v2 = c.value(2);

    assert_eq!(v2, 2);
}

이 테스트는 인자로 받은 값을 그대로 돌려주는 클로저가 포함된 새로운 Cacher 인스턴스를 생성 합니다. arg 값을 1로 그리고 arg 값을 2로 해서 이 Cacher 인스턴스의 value 메소드를 호출하고, arg 값을 2로 value 를 호출 했을 때 2를 반환 할 것으로 기대 합니다.

리스트 13-9 와 13-10 에 있는 Cacher 구현에 대해 이 테스트를 돌리면, 테스트는 이 메세지와 함께 assert_eq! 에서 실패 할 것입니다:

thread 'call_with_different_values' panicked at 'assertion failed: `(left == right)`
  left: `1`,
 right: `2`', src/main.rs

문제는 처음 c.value 을 1로 호출 했을 때, Cacher 인스턴스는 self.valueSome(1) 을 저장 합니다. 그 후에, value 값으로 무엇을 넘기던, 항상 1을 반환 할 것입니다.

Cacher 이 하나의 값보다 해시맵을 사용하도록 수정해 봅시다. 해시맵의 키는 넘겨받은 arg 값이 될 것이고, 해시맵의 값은 그 키로 클로저를 호출한 결과가 될 것입니다. self.valueSome 혹은 None 값인지 직접 살펴보는 대신, value 함수는 해시맵의 arg 값을 살펴보고 값이 있으면 반환 할 것입니다. 값이 없으면, Cacher 는 클로저를 호출해서 해당 arg 값과 연관된 해시맵에 결과값을 저장 할 것입니다.

현재 Cacher 구현의 두 번째 문제는 u32 타입 파라미터 한 개만 받고 하나의 u32 을 반환한다는 것입니다. 예를 들면, 문자열 슬라이스를 넘겨주고 usize 값을 반환하는 클로저의 결과를 캐시에 저장하고 싶을 수도 있습니다. 이 이슈를 수정 하기 위해, Cacher 기능에 유연성을 높여주도록 더 중립적인 파라미터를 사용해 봅시다.

클로저로 환경 캡처 하기

운동 생성 예제에서, 우리는 클로저를 단지 인라인 익명 함수로 사용 했습니다. 그러나 클로저는 함수에 없는 추가적인 능력을 갖고 있습니다: 환경을 캡처해서 클로저가 정의된 스코프의 변수들을 접근할 수 있습니다.

equal_to_x 변수에 저장된 클로저가 클로저를 둘러싼 환경에 있는 x 변수를 사용하는 예제가 리스트 13-12 에 있습니다:

파일명: src/main.rs

fn main() {
    let x = 4;

    let equal_to_x = |z| z == x;

    let y = 4;

    assert!(equal_to_x(y));
}

리스트 13-12: 둘러싼 범위에 있는 변수를 참조하는 클로저의 예

비록 xequal_to_x 의 파라미터 중에 하나가 아니더라도, equal_to_xequal_to_x 가 정의된 동일한 스코프에 정의된 x 변수를 사용하는 것이 허용 됩니다.

함수로는 이와 동일하게 할 수 없습니다; 다음 예제로 시도해 보면, 코드는 컴파일 되지 않습니다:

Filename: src/main.rs

fn main() {
    let x = 4;

    fn equal_to_x(z: i32) -> bool { z == x }

    let y = 4;

    assert!(equal_to_x(y));
}

에러가 발생 합니다:

error[E0434]: can't capture dynamic environment in a fn item; use the || { ...
} closure form instead
 --> src/main.rs
  |
4 |     fn equal_to_x(z: i32) -> bool { z == x }
  |                                          ^

컴파일러는 이것은 클로저에서만 동작한다고 상기시켜 주기까지 합니다!

클로저가 그것의 환경에서 값을 캡처할 때, 클로저 바디에서 사용하기 위해 그 값을 저장하기 위한 메모리를 사용 합니다. 이 메모리 사용은 환경을 캡처하지 않는 코드를 실행하길 원하는 더 흔한 상황에서는 지불하기 싶지 않은 오버헤드 입니다. 왜냐하면 함수는 그들의 환경을 캡처할 수 없기 때문에, 함수를 정의하고 사용하는데 결코 이런 오버헤드는 발생하지 않을 것이기 때문 입니다.

클로저는 세가지 방식으로 그들의 환경에서 값을 캡처 할 수 있는데, 함수가 파라미터 를 받는 세가지 방식과 직접 연결 됩니다: 소유권 받기, 불변으로 빌려오기, 가변으로 빌려오기. 이것들은 다음과 같이 세개의 Fn 트레잇으로 표현 합니다:

  • FnOnce 는 클로저의 환경으로 알고 있는, 그것을 둘러싼 환경에서 캡처한 변수 들을 소비합니다. 캡처한 변수를 소비하기 위해, 클로저는 이 변수의 소유권을 가져야 하고 그것이 정의될 때 클로저 안으로 그것들을 옮겨와야 합니다. 이름의 일부인 Once 는 그 클로저가 동일한 변수들에 대해 한번이상 소유권을 얻을수 없다는 사실을 의미하며, 그래서 한 번만 호출 될 수 있습니다.
  • Fn 은 그 환경으로 부터 값들을 불변으로 빌려 옵니다.
  • FnMut 값들을 가변으로 빌려오기 때문에 그 환경을 변경할 수 있습니다.

우리가 클로저를 만들때, 러스트는 클로저가 환경에 있는 값을 어떻게 사용하는지에 근거 해서 어떤 트레잇을 사용할지 추론 합니다. 리스트 13-12 에서, equal_to_x 클로저의 바디에서는 x 에 있는 값을 읽기만 하면 되기 때문에 클로저는 x 를 불변으로 빌려 옵니다. (그래서 equal_to_xFn 트래잇 입니다)

만약 클로저가 환경으로부터 사용하는 값에 대해 소유권을 갖도록 강제하고 싶다면, 파라미터 리스트 앞에 move 키워드를 사용할 수 있습니다. 이 기법은 클로저를 다른 쓰레드로 넘길때 데이터를 이동시켜 새로운 쓰레드가 소유하도록 할때 대부분 유용 합니다.

16장에 병렬성에 대해 이야기 하는 부분에서 더 많은 move 클로저에 대한 예제가 있습니다. 지금은 리스트 13-12 의 코드에서 클로저에 move 키워드를 추가하고 정수 대신 벡터를 사용하도록 했는데, 정수는 이동되지 않고 복사되기 때문 입니다; 이 코드는 아직 컴파일 되지 않습니다:

파일명: src/main.rs

fn main() {
    let x = vec![1, 2, 3];

    let equal_to_x = move |z| z == x;

    println!("can't use x here: {:?}", x);

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

    assert!(equal_to_x(y));
}

아래와 같은 에러가 발생합니다:

error[E0382]: use of moved value: `x`
 --> src/main.rs:6:40
  |
4 |     let equal_to_x = move |z| z == x;
  |                      -------- value moved (into closure) here
5 |
6 |     println!("can't use x here: {:?}", x);
  |                                        ^ value used here after move
  |
  = note: move occurs because `x` has type `std::vec::Vec<i32>`, which does not
  implement the `Copy` trait

move 키워드를 추가했기 때문에 클로저가 정의될 때 x 값은 클로저 안으로 이동됩니다. x 의 소유권은 클로저가 갖게 되었고, main 은 더 이상 println! 문에서 x 사용하도록 허용되지 않습니다. println! 를 삭제하면 이 예제는 수정 됩니다.

Fn 트래잇 바운드 중 하나를 기술할 때 대부분의 경우, Fn 으로 시작해보면 컴파일러는 클로저 바디에서 무슨일을 하는지에 근거해서 FnMut 혹은 FnOnce 이 필요한지 말해 줍니다.

클로저가 그들의 환경을 캡처할 수 있는 상황을 표현하는 것은 함수 파라미터로써 유용 합니다. 다음 주제로 넘어가 봅시다: 반복자.

반복자로 일련의 항목들 처리하기

반복자 패턴은 일련의 항목들에 대해 순서대로 어떤 작업을 수행할 수 있도록 해줍 니다. 반복자는 각 항목들을 순회하고 언제 시퀀스가 종료될지 결정하는 로직을 담당 합니다. 반복자를 사용하면, 저런 로직을 다시 구현할 필요가 없습니다.

러스트에서, 반복자는 게으른데, 항목들을 사용하기위해 반복자를 소비하는 메서드를 호출하기 전까지 반복자는 아무런 동작을 하지 않습니다. 예를 들면, 리스트 13-13 의 코드는 Vec 에 정의된 iter 메서드를 호출함으로써, 벡터 v1 에 있는 항목들에 대한 반복자를 생성 합니다. 이 코드 자체로는 어떤 유용한 동작을 하진 않습니다.


# #![allow(unused_variables)]
#fn main() {
let v1 = vec![1, 2, 3];

let v1_iter = v1.iter();
#}

리스트 13-13: 반복자 생성하기

일단 반복자를 만들면, 다양한 방법으로 사용할 수 있습니다. 3장의 리스트 3-5 에서, 각 항목에 대해 어떤 코드를 수행하기 위해 for 루프에서 반복자를 사용 했습니다만, 지금까지 iter 에 대한 호출이 무엇을 했는지 대충 넘어 갔었습니다.

리스트 13-14 의 예제는 for 루프에서 반복자를 사용하는 부분에서 반복자 생성을 분리 했습니다. 반복자는 v1_iter 변수에 저장되고, 그 시점에 순회는 발생하지 않습니다. v1_iter 에 있는 반복자를 사용하는 for 루프가 호출되면, 루프 순회 마다 반복자의 각 요소가 사용되는데, 각각의 값을 출력 합니다.


# #![allow(unused_variables)]
#fn main() {
let v1 = vec![1, 2, 3];

let v1_iter = v1.iter();

for val in v1_iter {
    println!("Got: {}", val);
}
#}

리스트 13-14: for 루프에서 반복자 사용하기

표준 라이브러리에서 반복자를 제공하지 않는 언어에서는, 변수를 인덱스 0으로 시작해서, 그 변수로 벡터를 색인해서 값을 가져오는데 사용하며, 루프안에서 벡터에 있는 아이템의 총 갯수까지 그 변수를 증가시키는 방식으로 동일한 기능을 작성할 수 있습니다.

반복자는 그러한 모든 로직을 대신 처리 하며, 잠재적으로 엉망이 될 수 있는 반복적인 코드를 줄여 줍니다. 반복자는 벡터처럼 색인할 수 있는 자료구조 뿐만 아니라, 많은 다른 종류의 시퀀스에 대해 동일한 로직을 사용할 수 있도록 더 많은 유연성을 제공 합니다. 반복자가 어떻게 그렇게 하는지 살펴 봅시다.

Iterator트레잇과 next 메서드

모든 반복자는 표준 라이브러리에 정의된 Iterator 라는 이름의 트레잇을 구현 합니 다. 트레잇의 정의는 아래와 같습니다:


# #![allow(unused_variables)]
#fn main() {
trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // methods with default implementations elided
}
#}

이 정의는 몇 개의 새로운 문법을 사용하는 것에 유의하세요: type ItemSelf::Item 은 이 트레잇과 연관 타입 을 정의 합니다. 우리는 19장에서 연관 타입에 대해 자세히 이야기 할 것 입니다. 지금 당장 알아야 할 것은 이 코드가 Iterator 트레잇을 구현하는 것은 Item 타입을 정의하는 것 또한 요구하며, 이 Item 타입이 next 메서드의 리턴 타입으로 사용된다는 것을 나타낸다는 것 입니 다. 다른 말로, Item 타입은 반복자로 부터 반환되는 타입이 될 것 입니다.

Iterator 트레잇은 단지 구현자가 하나의 메서드를 정의하도록 요구 합니다: next 메서드 입니다. 이 메서드는 반복자의 하나의 항목을 Some 에 넣어서 반환 하고, 반복자가 종료되면 None 을 반환 합니다.

반복자의 next 메서드를 직접 호출할 수 있습니다; 리스트 13-15 는 벡터로 부터 생성된 반복자에 대해 반복된 next 호출이 어떤 값들을 반환하는지 보여줍니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[test]
fn iterator_demonstration() {
    let v1 = vec![1, 2, 3];

    let mut v1_iter = v1.iter();

    assert_eq!(v1_iter.next(), Some(&1));
    assert_eq!(v1_iter.next(), Some(&2));
    assert_eq!(v1_iter.next(), Some(&3));
    assert_eq!(v1_iter.next(), None);
}
#}

리스트 13-15: 반복자의 next 메서드 호출하기

v1_iter 가 변경 가능하도록 만들 필요가 있다는 것에 유의 하세요: 반복자에 대해 next 메서드를 호출하면 시퀀스의 어디에 있는지 추적하기 위해 반복자가 사용하는 내부 상태를 변경합니다. 다른 말로, 이 코드는 반복자를 소비 합니다, 혹은 다 써 버립니다. next 에 대한 각 호출은 반복자로 부터 하나의 항목을 소비 합니다. for 루프를 사용할 때는 v1_iter 를 변경할 수 있도록 만들 필요가 없는데, 루프가 v1_iter 의 소유권을 갖고 내부적으로 변경 가능하도록 만들기 때문 입니다.

next 호출로 얻어온 값들은 벡터 안에 있는 값들에 대한 불변 참조라는 점 역시 유의 하세요. iter 메서드는 불변 참조에 대한 반복자를 만듭니다. 만약 v1 의 소유권을 갖고 소유된 값들을 반환하도록 하고 싶다면, iter 대신 into_iter 를 호출해야 합니다. 비슷하게, 가변 참조에 대한 반복자를 원한다면, iter 대신 iter_mut 을 호출할 수 있습니다.

반복자를 소비하는 메서드들

Iterator 트레잇에는 표준 라이브러리에서 기본 구현을 제공하는 다수의 다른 메서드들이 있습니다; Iterator 트레잇에 대한 표준 라이브러리 API 문서를 살펴 보면, 이 메서드들을 찾을 수 있습니다. 이 메서드들 중 일부는 그들의 구현에서 next 메서드를 호출하는데, 이것이 Iterator 트레잇을 구현할 때 next 메서드를 구현해야만 하는 이유 입니다.

next 를 호출하는 메서드들을 소비하는 어댑터들 이라고 하는데, 그들을 호출하면 반복자를 써버리기 때문 입니다. sum 메서드가 하나의 예인데, 반복자의 소유권을 가져오고 반복적으로 next 를 호출해서 순회함으로써 반복자를 소비 합니다. 순회해 나가면서 누적합계에 각 아이템을 더하고 순회가 완료되면 합계를 반환 합니다. 리스트 13-16 은 sum 메서드의 사용을 보여주는 테스트 입니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[test]
fn iterator_sum() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    let total: i32 = v1_iter.sum();

    assert_eq!(total, 6);
}
#}

리스트 13-16: 반복자의 모든 항목에 대한 합계를 얻기 위해 sum 메서드 호출 하기

sum 은 호출한 반복자의 소유권을 갖기 때문에, sum 을 호출한 후 v1_iter 은 사용할 수 없습니다.

다른 반복자를 생성하는 메서드들

Iterator 트레잇에 정의된 다른 메서드들 중에 반복자 어댑터들 로 알려진 메서드 들은 반복자를 다른 종류의 반복자로 변경하도록 허용 합니다. 복잡한 행위를 수행하 기 위해 읽기 쉬운 방법으로 반복자 어댑터에 대한 여러개의 호출을 연결할 수 있습 니다. 하지만 모든 반복자는 게으르기 때문에, 반복자 어댑터들로 부터 결과를 얻기 위해 소비하는 메서드들 중 하나를 호출 해야 합니다.

리스트 13-17 은 반복자 어댑터 메서드인 map 을 호출하는 예를 보여주는데, 새로운 반복자를 생성하기 위해 각 항목에 대해 호출할 클로저를 인자로 받습니다. 여기서 클로저는 벡터의 각 항목에서 1이 증가된 새로운 반복자를 만듭니다. 그러나, 이 코드는 경고를 발생 합니다:

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
let v1: Vec<i32> = vec![1, 2, 3];

v1.iter().map(|x| x + 1);
#}

리스트 13-17: 새로운 반복자를 만들기 위해 반복자 어댑터 map 호출 하기

경고 메세지는 이것 입니다:

warning: unused `std::iter::Map` which must be used: iterator adaptors are lazy
and do nothing unless consumed
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: #[warn(unused_must_use)] on by default

리스트 13-17 의 코드는 아무것도 하지 않습니다; 인자로 넘긴 클로저는 결코 호출 되지 않습니다. 경고는 이유를 알도록 해주니다: 반복자 어댑터는 게으르고, 반복자를 여기서 소비할 필요가 있다.

이것을 고치고 반복자를 소비하기 위해, collect 메서드를 사용할 것인데, 12장의 리스트 12-1 에서 env::args 와 함께 사용했습니다. 이 메서드는 반복자를 소비하고 결과값을 수집 데이터 타입으로 모읍니다.

리스트 13-18 에서, 벡터에 대한 map 호출로 부터 반환된 반복자를 순회하면서 결과를 모읍니다. 이 벡터는 각 항목이 원본 벡터로 부터 1씩 증가된 상태로 될 것 입니다.

Filename: src/main.rs


# #![allow(unused_variables)]
#fn main() {
let v1: Vec<i32> = vec![1, 2, 3];

let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

assert_eq!(v2, vec![2, 3, 4]);
#}

리스트 13-18: 새로운 반복자를 만들기 위해 map 메서드를 호출하고, 새로운 반복자를 소비하고 벡터를 생성하기 위해 collect 메서드 호출 하기

map 은 클로저를 인자로 받기 때문에, 각 항목에 대해 수행하기를 원하는 어떤 연산도 기술할 수 있습니다. 이것은 Iterator 트레잇이 제공하는 반복자 행위를 재사용 하면서 클로저가 어떻게 일부 행위를 맞춤 조작할 수 있는지를 보여주는 굉장한 예제 입니다.

환경을 캡쳐하는 클로저 사용하기

이제 반복자를 소개했으니, filter 반복자 어댑터를 사용해서 환경을 캡쳐하는 클로저의 일반적인 사용을 보여줄 수 있습니다. 반복자의 filter 메서드는 반복자로 부터 각 항목을 받아 Boolean 을 반환하는 클로저를 인자로 받습니다. 만약 클로저가 true 를 반환하면, 그 값은 filter 에 의해 생성되는 반복자에 포함될 것 입니다. 클로저가 false 를 반환하면, 결과로 나오는 반복자에 포함되지 않을 것 입니다.

리스트 13-19 에서, Shoe 구조체 인스턴스들의 컬렉션을 순회하기 위해 filter 와 그 환경으로 부터 shoe_size 변수를 캡쳐하는 클로저를 사용 합니다. 그것은 기술된 크기의 신발들만 반환 할 것 입니다.

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_my_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter()
        .filter(|s| s.size == shoe_size)
        .collect()
}

#[test]
fn filters_by_size() {
    let shoes = vec![
        Shoe { size: 10, style: String::from("sneaker") },
        Shoe { size: 13, style: String::from("sandal") },
        Shoe { size: 10, style: String::from("boot") },
    ];

    let in_my_size = shoes_in_my_size(shoes, 10);

    assert_eq!(
        in_my_size,
        vec![
            Shoe { size: 10, style: String::from("sneaker") },
            Shoe { size: 10, style: String::from("boot") },
        ]
    );
}
#}

리스팅 13-19: shoe_size 를 캡쳐하는 클로저와 filter 메서드 사용하기

shoes_in_my_size 함수는 파라미터로 신발들의 벡터에 대한 소유권과 신발 크기를 받습니다. 그것은 지정된 크기의 신발들만을 포함하는 벡터를 반환 합니다.

shoes_in_my_size 의 구현부에서, 벡터의 소유권을 갖는 반복자를 생성하기 위해 into_iter 를 호출 합니다. 그 다음 그 반복자를 클로저가 true 를 반환한 요소들만 포함하는 새로운 반복자로 바꾸기 위해 filter 를 호출 합니다.

클로저는 환경에서 shoe_size 매개 변수를 캡처하고, 지정된 크기의 신발만 유지하면서 각 신발의 크기와 값을 비교합니다. 마지막으로,collect를 호출하면 적용된 반복자에 의해 리턴된 값을 함수가 리턴한 벡터로 모으게됩니다.

테스트는 shoes_in_my_size 를 호출 했을 때, 지정된 값과 동일한 사이즈를 갖는 신발들만 돌려받는 다는 것을 보여 줍니다.

Iterator 트레잇으로 자신만의 반복자 만들기

벡터에 대해 iter, into_iter 혹은 iter_mut 을 호출해서 반복자를 생성할 수 있다는 것을 보았습니다. 해시맵과 같은 표준 라이브러리에 있는 다른 컬렉션 타입으로 부터 반복자를 생성할 수 있습니다. 자신만의 타입에 대해 Iterator 트레잇을 구현함으로써 원하는 동작을하는 반복자를 생성하는것 역시 가능 합니다. 이전에 언급했던 것 처럼, 정의를 제공해야 하는 유일한 메서드는 next 메서드 입 니다. 그러고 나면, Iterator 트레잇에서 제공하는 기본구현을 갖는 다른 모든 메서드를 사용할 수 있습니다!

이것을 보여주기 위해 1부터 5까지 셀 수있는 반복자를 만듭니다. 우선, 어떤 값들을 유지하는 구조체를 만들 것 입니다. 그 다음 Iterator 트레잇을 구현하고 그 구현에서 값들을 사용함으로써 이 구조체를 반복자로 만들 것 입니다.

리스트 13-20 에는 Counter 구조체의 정의와 Counter 인스턴스를 생성하는 연관된 new 함수가 있습니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}
#}

리스트 13-20: Counter 구조체와 count 의 초기값 0 으로 Counter 의 인스턴스를 생성하는 new 함수 정의하기

Counter 구조체는 count 라는 이름의 하나의 필드를 갖습니다. 이 필드는 u32 타입의 값을 갖는데 1부터 5까지 순회하는데 어디까지 진행했는지를 추적할 것 입니다. count 필드는 Counter 구현이 그 값을 관리하길 원하기 때문에 외부로 노출되지 않습니다. new 함수는 항상 새로운 인스턴스가 count 필드에 0을 담은 채로 시작하도록 강제합니다.

다음으로, 이 반복자가 사용될 때 우리가 원하는 것을 지정하기 위해 next 메소드의 본문을 정의함으로써 Counter 타입에 대한 Iterator 특성을 구현할 것입니다, 리스트 13-21 참조:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
# struct Counter {
#     count: u32,
# }
#
impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        self.count += 1;

        if self.count < 6 {
            Some(self.count)
        } else {
            None
        }
    }
}
#}

리스트 13-21: Counter 구조체에 대해 Iterator 트레잇 구현하기

우리의 반복자를 위해 연관된 Item 타입을 u32 로 지정했는데, 이는 반복자가 u32 값을 반환한다는 것을 의미 합니다. 다시, 아직 연관 타입에 대해 걱정하시 마세요, 19장에서 다룰 것입니다.

우리는 우리의 반복자가 현재 상태에 1을 더하길 원합니다, 그래서 count 를 0 으로 초기화 했고 처음엔 1을 반환할 것 입니다. count 의 값이 6 보다 작다면, nextSome 으로 포장된 현재 값을 리턴할 것이며, count 가 6 이거나 더 크다면, 우리의 반복자는 None 을 반환할 것 입니다.

Counter 반복자의 next 메서드 사용하기

Iterator 트레잇을 구현 했다면, 반복자를 갖게 됩니다! 리스트 13-22 는 리스트 13-15 에서 벡터로 부터 생셩된 반복자에 했던 것 처럼, Counter 구조체에 직접 next 메서드를 호출 함으로써 반복자 기능을 사용할 수 있다는 것을 보여주는 테스트를 보여 줍니다.

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
# struct Counter {
#     count: u32,
# }
#
# impl Iterator for Counter {
#     type Item = u32;
#
#     fn next(&mut self) -> Option<Self::Item> {
#         self.count += 1;
#
#         if self.count < 6 {
#             Some(self.count)
#         } else {
#             None
#         }
#     }
# }
#
#[test]
fn calling_next_directly() {
    let mut counter = Counter::new();

    assert_eq!(counter.next(), Some(1));
    assert_eq!(counter.next(), Some(2));
    assert_eq!(counter.next(), Some(3));
    assert_eq!(counter.next(), Some(4));
    assert_eq!(counter.next(), Some(5));
    assert_eq!(counter.next(), None);
}
#}

리스트 13-22: next 메서드 구현의 기능 테스트

이 테스트는 counter 변수에 새로운 Counter 인스턴스를 생성하고 next 를 반복적으로 호출하면서, 이 반복자가 우리가 원하는 행위를 구현했다는 것을 검증 합니다: 1 부터 5까지의 값을 반환함.

다른 Iterator 메서드들 사용하기

우리는 next 메서드를 정의함으로써 Iterator 트레잇을 구현했습니다, 그래서 표준 라이브러리에 정의된 Iterator 트레잇 메서드들의 기본 구현을 사용할 수 있 는데, 그들은 모두 next 메서드의 기능을 사용하기 때문 입니다.

예를 들면, 만약 어떤 이유에서든 Counter 인스턴스에 의해 생성된 값들을 얻고, 다른 Counter 인스턴스에 의해 생성된 값과 쌍을 이루며, 각 쌍을 함께 곱하고, 3으로 나눠지는 값들만 유지하며, 모든 결과 값을 함께 더하고 싶다면, 리스트 12-23 의 테스트에서 보여지는 것처럼, 그렇게 할 수 있습니다:

Filename: src/lib.rs


# #![allow(unused_variables)]
#fn main() {
# struct Counter {
#     count: u32,
# }
#
# impl Counter {
#     fn new() -> Counter {
#         Counter { count: 0 }
#     }
# }
#
# impl Iterator for Counter {
#     // Our iterator will produce u32s
#     type Item = u32;
#
#     fn next(&mut self) -> Option<Self::Item> {
#         // increment our count. This is why we started at zero.
#         self.count += 1;
#
#         // check to see if we've finished counting or not.
#         if self.count < 6 {
#             Some(self.count)
#         } else {
#             None
#         }
#     }
# }
#
#[test]
fn using_other_iterator_trait_methods() {
    let sum: u32 = Counter::new().zip(Counter::new().skip(1))
                                 .map(|(a, b)| a * b)
                                 .filter(|x| x % 3 == 0)
                                 .sum();
    assert_eq!(18, sum);
}
#}

리스트 13-23: Counter 반복자에 대해 Iterator 트레잇의 다양햔 메서드 사용하기

zip 은 단지 네 개의 쌍을 생성한다는데 유의 하세요; 이론적으로 다섯번째 쌍인 (5, None) 은 결코 생성되지 않는데, zip 은 입력 반복자 중 하나라도 None 을 반환하면 None 을 반환하기 때문 입니다.

우리가 next 메서드가 어떻게 동작하는지에 대해 기술했기 때문에 이 모든 메서드 호출이 가능하며, 표준 라이브러리는 next 를 호출하는 다른 메서드들의 기본 구현 을 제공 합니다.

I/O 프로젝트 개선하기

반복자에 대한 새로운 지식을 사용하여 12장의 I/O 프로젝트의 코드들을 더 깔끔하고 간결하게 개선할 수 있습니다. 반복자를 사용하여 어떻게 Config::new 함수와 search 함수의 구현을 개선할 수 있는지 살펴봅시다.

반복자를 사용하여 clone 제거하기

리스트 12-6 에서, String 값의 슬라이스를 받고 슬라이스를 인덱싱하고 복사 함으로써 Config 구조체의 인스턴스를 생성하였고, Config 구조체가 이 값들을 소유하도록 했습니다. 리스트 13-24 에서는 리스트 12-23 에 있던 것 처럼 Config::new 함수의 구현을 다시 재현 했습니다:

파일명: src/lib.rs

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config { query, filename, case_sensitive })
    }
}

리스트 13-24: 리스트 12-23 의 Config::new 함수 재현

그 당시, 비효율적인 clone 호출에 대해 걱정하지 말라고 얘기 했으며 미래에 없앨 것이라고 했습니다. 자, 그때가 되었습니다!

String 요소들의 슬라이스를 args 파라미터로 받았지만 new 함수는 args 를 소유하지 않기 때문에 clone 이 필요했습니다. Config 인스턴스의 소유권을 반환하기 위해, Configqueryfilename 필드로 값을 복제 함으로써 Config 인스턴스는 그 값들을 소유할 수 있습니다.

반복자에 대한 새로운 지식으로, 인자로써 슬라이스를 빌리는 대신 반복자의 소유권을 갖도록 new 함수를 변경할 수 있습니다. 슬라이스의 길이를 체크하고 특정 위치로 인덱싱을 하는 코드 대신 반복자의 기능을 사용할 것 입니다. 이것은 반복자가 값에 접근 할 것이기 때문에 Config::new 함수가 무엇을 하는지를 명확하게 해줄 것 입니다.

Config::new 가 반복자의 소유권을 갖고 빌린 값에 대한 인뎅싱을 사용하지 않게 된다면, clone 을 호출하고 새로운 할당을 만드는 대신 String 값들을 반복자에서 Config 로 이동할 수 있습니다.

반환된 반복자를 직접 사용하기

I/O 프로젝트의 src/main.rs 파일을 열어보면, 아래와 같을 것 입니다:

파일명: src/main.rs

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    // --snip--
}

우리는 리스트 12-24 에 있는 main 함수의 시작점을 리스트 13-25 에 있는 코드로 바꿀 것 입니다. 이것은 Config::new 도 업데이트 해야 컴파일 됩니다.

파일명: src/main.rs

fn main() {
    let config = Config::new(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    // --snip--
}

리스트 13-25: Config::newenv::args 의 반환값 넘기기

env::args 함수는 반복자를 반환 합니다! 반복자의 값들을 벡터로 모아서 Config::new 에 슬라이스를 넘기는 대신, env::args 에서 반환된 반복자의 소유권 을 Config::new 로 직접 전달 합니다.

그 다음, Config::new 정의를 업데이트 할 필요가 있습니다. I/O 프로젝트의 src/lib.rs 파일에서, 리스트 13-26 처럼 Config::new 의 시그니처를 변경 합시다. 함수 본문을 업데이트 해야 하기 때문이 아직 컴파일 되지 않습니다.

파일명: src/lib.rs

impl Config {
    pub fn new(mut args: std::env::Args) -> Result<Config, &'static str> {
        // --snip--

리스트 13-26: 반복자를 받도록 Config::new 의 시그니처 업데이트 하기

env::args 함수에 대한 표준 라이브러리 문서에는 반환하는 반복자의 타입이 std::env::Args 라고 명시되어 있습니다. Config::new 함수의 시그니처를 업데이 트 해서 args 파리미터가 &[String] 대신 std::env::Args 타입을 갖도록 했습니다. args 의 소유권을 갖고 그것을 순회하면서 args 를 변경할 것이기 때문에, 변경 가능하도록 하기 위해 args 파라미터의 명세에 mut 키워드를 추가 할 수 있습니다.

인덱싱 대신 Iterator 트레잇 메서드 사용하기

다음으로, Config::new 의 본문을 수정 할 것입니다. 표준 라이브러리 문서에는 std::env::ArgsIterator 트레잇을 구현하고 있다는 것 역시 언급하고 있으 므로, next 메서드를 호출 할 수 있다는 것을 알 수 있습니다! 리스트 13-27 은 리스트 12-23 의 코드에서 next 메서드를 사용하도록 변경 합니다:

Filename: src/lib.rs

# fn main() {}
# use std::env;
#
# struct Config {
#     query: String,
#     filename: String,
#     case_sensitive: bool,
# }
#
impl Config {
    pub fn new(mut args: std::env::Args) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let filename = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file name"),
        };

        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config { query, filename, case_sensitive })
    }
}

리스트 13-27: 반복자 메서드들을 사용하도록 Config::new 의 본문 변경하기

env::args 반환값의 첫번째 값은 프로그램 이름이라는 것을 명심하세요. 우리는 첫번째 값을 무시하고 그 다음 값을 얻기 위해 우선 next 를 호출한 다음, 그 반환값으로 아무것도 하지 않았습니다. 두번째로, Configquery 에 원하는 값을 넣기 위해 next 를 호출 했습니다. nextSome 을 반환하면, 값을 추출하기 위해 match 를 사용 합니다. 만약 None 을 반환하면, 이것은 충분한 인자가 넘어오지 않았다는 것을 의미하고, Err 값과 함께 조기 반환을 합니다. filename 값도 동일하게 처리 합니다.

반복자 어댑터로 더 간결한 코드 만들기

I/O 프로젝트의 search 함수에도 반복자의 장점을 활용할 수 있습니다. 리스트 12-19 의 코드가 리스트 13-28 에 재현되어 있습니다:

파일명: src/lib.rs

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

리스트 13-28: 리스트 12-19 의 search 함수 구현

우리는 반복자 어댑터 메서드를 사용해서 이 코드를 더 간결한 방식으로 작성할 수 있습니다. 이렇게 함으로써 results 벡터가 변경 가능한 중간 상태를 갖는 것을 피할 수 있습니다. 함수형 프로그래밍 스타일은 더 깔끔한 코드를 만들기 위해 변경 가능한 상태의 양을 최소화 하는 것을 선호 합니다. 가변 상태를 제거하면 results 벡터에 대한 동시 접근을 관리 할 필요가 없기 때문에, 추후에 검색을 병렬로 수행하는 것과 같은 향상이 가능해 집니다. 리스트 13-29 는 이 변경을 보여줍니다:

파일명: src/lib.rs

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents.lines()
        .filter(|line| line.contains(query))
        .collect()
}

리스트 13-29: search 함수 구현에서 반복자 어댑터 메서드 사용하기

search 함수의 목적은 query 를 포함하는 contents 의 모든 줄을 반환하는 것임 을 기억하세요. 리스트 13-19 의 filter 예제와 유사하게, 이 코드는 line.contains(query)true 를 반환하는 줄들만 유지하기 위해 filter 어댑 터를 사용 합니다. 그러고나서 collect 를 통해서 일치하는 줄들을 모아 새로운 벡터로 만듭니다. 훨씬 단순합니다! search_case_insensitive 도 역시 반복자 메서드들을 사용하도록 같은 변경을 자유롭게 만들어 보세요.

다음 논리적 질문은 당신의 코드에서 어떤 스타일을 선택하는 것이 좋은지와 그 이유 입니다: 리스트 13-28 의 최초 구현 혹은 리스트 13-29 의 반복자를 사용하는 버전. 대부분의 러스트 프로그래머는 반복자 스타일을 선호 합니다. 처음 사용하기는 다소 어렵습니다만, 다양한 반복자 어댑터와 어떤 일을 하는지에 대해 한번 감이 온다면, 반복자들은 이해하기 쉬워질 것 입니다. 루핑과 새로운 벡터 생성과 같은 다양한 작업을 수행하는 대신, 코드는 루프의 고차원적 목표에 집중 합니다. 이것은 아주 흔한 코드의 일부를 추상화해서 제거함으로써 반복자의 각 요소가 반드시 통과 해야하는 필터링 조건과 같이 이 코드에 유일한 개념을 더 쉽게 볼 수 있도록 합니다.

그러나 두 구현은 정말 동일 할까요? 직관적으로 저수준의 루프가 더 빠르다고 가정할 수도 있습니다. 그럼 성능에 대해서 얘기해 봅시다.

성능 비교하기: 루프 vs. 반복자

루프와 반복자 중에 어떤것을 사용할지 결정하기 위해, 어떤 버전의 search 함수가 더 빠른지 알 필요가 있습니다: 명시적으로 for 루프를 사용한 버전과 반복자를 사용한 버전.

우리는 아서 코난 도일이 쓴 셜록 홈즈의 모험 의 전체 내용을 로딩하고 내용중에 the 를 찾는 벤치마크를 돌렸습니다. 여기 search 루프와 반복자를 사용한 버전 에 대한 벤치마크 결과가 있습니다:

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

반복자 버전이 약간더 빠릅니다! 여기서 벤치마크 코드에 대해 설명하진 않을 것 입니 다. 왜냐하면 핵심은 두 버전이 동등하다는 것을 증명하는 것이 아니고, 이 두 구현 방법이 성능 측면에서 어떻게 다른지에 대한 상식적인 이해를 얻는 것이기 때문 입니 다.

더 포괄적인 벤치마크를 위해, 다양한 크기의 다양한 텍스트를 내용 으로 사용하고, 다른 길이의 다른 단어들을 질의어 로 사용해서 모든 종류의 다른 조합을 확인 하는 것이 좋습니다. 핵심은 이렇습니다: 반복자는 비록 고수준의 추상이지만, 컴파일 되면 대략 직접 작성한 저수준의 코드와 같은 코드 수준으로 내려갑니다. 반복자는 러스트의 제로 비용 추상화 중 하나이며, 그 추상을 사용하는 것은 추가 적인 실행시간 오버헤드가 없다는 것을 의미 합니다. 최초의 C++ 디자이너 이자 구현자인 비야네 스트롭스트룹이 “Foundations of C++” (2012) 에서 제로 오버헤드 를 정의한 것과 유사 합니다:

일반적으로, C++ 구현은 제로-오버헤드 원리를 따릅니다: 사용하지 않는 것은, 비용을 지불하지 않습니다. 그리고 더 나아가: 사용하는 것은, 더 나은 코드를 제공할 수 없습니다.

다른 예로, 다음 코드는 오디오 디코더에서 가져왔습니다. 디코딩 알고리즘은 이전 샘플의 선형 함수에 기반해서 미래의 값을 추정하기 위해 선형 예측이라는 수학적 연산을 사용합니다. 이 코드는 반복자 체인을 사용해서 스코프에 있는 세 개의 변수로 수학 연산을 합니다: 데이터의 buffer 슬라이스, 12 개의 coefficients 배열, 그리고 데이터를 쉬프트 하기 위한 qlp_shift 값. 이 예제에서 변수를 선언 했지만 값은 주지 않았습니다; 이 코드는 이 문맥밖에서는 크게 의미가 없지만, 러스트가 어떻게 고수준의 개념을 저수준의 코드로 변환하는지 에 대한 간결하고 실제적인 예제 입니다.

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
    let prediction = coefficients.iter()
                                 .zip(&buffer[i - 12..i])
                                 .map(|(&c, &s)| c * s as i64)
                                 .sum::<i64>() >> qlp_shift;
    let delta = buffer[i];
    buffer[i] = prediction as i32 + delta;
}

prediction 의 값을 계산하기 위해, 이 코드는 coefficients 에 있는 12개의 값을 순회하면서 각각의 계수와 buffer 의 이전 12개의 값의 쌍을 만들기 위해 zip 메서드를 사용 합니다. 그런 다음, 각 쌍에 대해 값들을 모두 곱하고 모든 결과를 더한 후 더한 값을 qlp_shift 비트 만큼 우측으로 쉬프트 합니다.

오디오 디코더와 같은 어플리케이션에서의 계산은 종종 성능에 가장 높은 우선순위를 둡니다. 여기서 우리는 두 개의 어댑터를 사용하는 반복자를 생성하고 값을 소비 했습니다. 이 러스트 코드가 컴파일 되면 어떤 어셈블리 코드가 될 까요? 글쎄요, 이 글을 쓰는 시점에선 그것은 직접 손으로 작성한 것과 같은 어셈블리 코드로 컴파일 됩니다. 거기엔 coefficients 의 값들을 순회하기 위한 어떤 루프도 없습니다: 러스트는 12개의 순회가 있다는 것을 알고 있으며, 루프를 "풀어(unrolls)" 놓습니다. 언롤링(Unrolling) 은 루프 제어 코드의 오버헤드를 제거하고 대신 루프의 각 순회에 해당하는 반복적인 코드를 생성하는 최적화 방법 입니다.

모든 계수들은 레지스터에 저장되는데 값에 대한 접근이 매우 빠르다는 것을 뜻합 니다. 실행시간에 배열 접근에 대한 경계 체크가 없습니다. 러스트가 적용할 수 있는 이런 모든 최적화들은 결과 코드를 아주 효율적으로 만듭니다. 이제 이것을 알게 되었으니, 반복자와 클로저를 공포없이 사용할 수 있습니다! 이것들은 코드를 고수준 으로 보이도록 하지만, 그렇게 하기 위해 실행시간 성능 저하를 만들지 않습니다.

요약

클로저와 반복자는 함수형 프로그래밍 아이디어에서 영감을 받은 러스트의 특징들 입니다. 이것들은 고수준의 개념을 저수준의 성능으로 명확하게 표현할 수 있는 러스트의 능력에 기여하고 있습니다. 클로저와 반복자의 구현들은 런타임 성능에 영향을 미치지 않습니다. 이것은 제로-비용 추상을 제공하기 위해 노력하는 러스트의 목표 중의 일부 입니다.

이제 I/O 프로젝트의 표현력을 개선 했으니, 프로젝트를 세상과 공유하는데 도움을 줄 cargo 의 몇몇 특징들을 살펴 봅시다.

Cargo 와 Crates.io 더 알아보기

지금까지 우린 빌드, 실행, 코드 테스트등 Cargo 의 가장 기본적인 기능만 사용하였지만, Cargo 는 훨씬 더 많은 일을 할 수 있습니다. 이번 장에서 다음 목록의 기능을 수행하는 고급 기능 몇가지를 알아보도록 하겠습니다.

  • 릴리즈 프로필을 이용해 빌드 커스터마이징하기
  • crates.io 에 라이브러리 배포하기
  • 대규모 작업을 위한 작업공간 구성하기
  • crates.io 에서 바이너리 설치하기
  • 커스텀 명령어로 Cargo 확장하기

Cargo 는 이번 장에서 다루는 것보다 더 많은 일을 할 수 있습니다. 만약 Cargo 의 모든 기능에 대한 설명을 보고 싶으시다면 Cargo 공식 문서 를 참고하세요.

릴리즈 프로필을 이용해 빌드 커스터마이징하기

러스트에서 릴리즈 프로필(release profiles) 은 프로그래머가 코드 컴파일에 관련된 여러가지 옵션을 제어할 수 있도록 다양한 구성으로 사전 정의되고 커스텀 가능한 프로필입니다. 각 프로필은 다른 프로필과 독립적으로 설정됩니다.

Cargo 는 두 메인 프로필을 가집니다: 여러분이 cargo build 를 실행할때 쓰는dev 프로필과 cargo build --release 를 실행할때 쓰는 release 프로필 입니다. dev 프로필은 개발에 적합한 설정을 기본값으로 갖고, release 프로필은 릴리즈 빌드용 설정을 기본값으로 가집니다.

여러분은 빌드 출력에서 이 프로필들의 이름을 몇 번 보셨을 수도 있습니다.

$ cargo build
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
$ cargo build --release
    Finished release [optimized] target(s) in 0.0 secs

위 출력의 devrelease 는 컴파일러가 다른 프로필을 사용한다는 것을 나타냅니다.

Cargo 는 프로젝트의 Cargo.toml 파일에 [profile.*] 구획이 따로 없을때 적용되는 각 프로필의 기본 설정을 가지고 있습니다. 이때 여러분은 원하는 프로필에 [profile.*] 구획을 추가하여 기본 설정을 덮어 씌울 수 있습니다. 여기 예시로 devrelease 프로필 각각의 opt-level 기본 설정 값을 보여드리겠습니다.

Filename: Cargo.toml

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

opt-level 설정은 러스트가 여러분의 코드에 적용할 최적화 수치이며, 0 ~ 3 사이의 값을 가집니다. 여러분이 개발을 할 때와 같이 코드를 자주 컴파일 하는 상황에서는 코드의 실행 속도가 조금 느려지는 한이 있더라도 컴파일이 빨리 되길 원합니다. 하지만 높은 최적화 수치를 적용 할 수록 컴파일에 걸리는 시간은 증가합니다. 따라서 dev 의 기본 opt-level 값은 0 으로 되어 있습니다. 만약 여러분이 코드를 릴리즈 하려 한다면, 컴파일에 걸리는 시간이 늘어나도 상관이 없을 겁니다. 릴리즈 할 경우 컴파일은 한 번이지만, 실행 횟수는 여러번 이니까요. 따라서 릴리즈 모드에서는 컴파일 시간을 희생하는 대신 빠른 코드 실행 속도를 얻기 위해 release 프로필의 기본 opt-level 값이 3 으로 되어 있습니다.

이전에 말했듯, 여러분은 Cargo.toml 에 다른 값을 넣어서 기본 설정을 덮어 씌울 수 있습니다. 예를 들어 만약 우리가 개발용 프로필에 0 이 아닌 1 의 최적화 수치를 적용하고 싶다면 우리 프로젝트의 Cargo.toml 에 다음 두 줄을 추가하면 됩니다:

Filename: Cargo.toml

[profile.dev]
opt-level = 1

이 코드는 기본 설정인 0 을 덮어 씌웁니다. 이후에 우리가 cargo build 를 실행하면 Cargo 는 dev 프로필의 기본값과 우리가 커스텀 한 opt-level 을 사용합니다. 우리가 opt-level1 로 설정 했기 때문에 Cargo 는 릴리즈 빌드 만큼은 아니지만 기본 설정 보다 많은 최적화를 진행할 겁니다.

각 프로필의 설정 옵션 및 기본값의 전체 목록을 보시려면 Cargo 공식 문서 를 참고해 주시기 바랍니다.

Crates.io 에 크레이트 배포하기

우린 crates.io 의 패키지를 프로젝트의 의존성으로만 사용했지만 여러분이 직접 여러분의 패키지를 배포(publish)해서 코드를 다른 사람들과 공유 할 수도 있습니다. crates.io 의 크레이트 등기소 (registry)는 여러분이 만든 패키지의 소스코드를 배포하므로, crates.io 는 주로 오픈 소스인 코드를 관리합니다.

러스트와 Cargo 는 여러분이 배포한 패키지를 사람들이 더 쉽게 찾고 사용할 수 있도록 도와주는 기능이 있습니다. 다음 내용이 바로 이런 기능들 몇개에 대한 설명과 패키지를 배포하는 방법에 대한 설명입니다.

유용한 문서화 주석 만들기

여러분의 패키지를 시간을 들여서 자세하게 문서화하는 작업은 굉장히 가치있는 일 입니다. 문서는 다른 사람들이 그 패키지를 언제, 어떻게 써야할지 알게 해주는데 굉장히 도움이 되거든요. 3장에서 우린 슬래시 두 개(//) 를 이용해 러스트 코드에 주석을 남기는 법을 배웠습니다만, 러스트에는 문서화 주석(documentation comment) 이라고 불리는 문서화를 위한 특별한 주석이 존재합니다. 이 주석은 HTML 문서를 생성할 수 있는데, 이 HTML 에는 여러분의 크레이트가 어떻게 구현되었는지 가 아닌 어떻게 사용하는지 에 관심 있는 프로그래머들을 위한 공개 API의 문서화 주석이 보여집니다.

문서화 주석은 슬래시 두 개가 아니라 세 개(///) 를 이용하며 텍스트 서식을 위한 마크다운 표기법을 지원합니다. 문서화 주석은 문서화할 대상 바로 이전에 배치하면 됩니다. Listing 14-1 은 my_crate 크레이트의 add_one 함수에 대한' 문서화 주석의 예시를 보여줍니다:

Filename: src/lib.rs

/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let five = 5;
///
/// assert_eq!(6, my_crate::add_one(5));
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}

Listing 14-1: 함수에 대한 문서화 주석

자, add_one 함수가 무슨 일을 하는지 설명을 적었고 Example 절에서 add_one 함수를 어떻게 사용하는지에 대한 예시 코드를 제공 했습니다. 이제 우린 cargo doc 을 이용해 이 문서화 주석으로부터 HTML 문서를 생성할 수 있습니다. 이 명령어는 러스트에 들어있는 rustdoc 툴을 실행시키고 생성된 HTML 문서를 target/doc 디렉토리에 저장합니다.

좀더 편리하게, cargo doc --open 을 실행시키면 여러분의 현재 크레이트의 문서에 대해 (심지어 여러분의 크레이트가 가진 모든 디펜던시의 문서까지) HTML 을 생성하고 웹 브라우저에 띄워줄 겁니다. 이제 add_one 함수를 찾아보면 여러분은 문서화 주석의 내용이 어떻게 나타나는지 보실 수 있습니다. Figure 14-1 처럼요:

Rendered HTML documentation for the `add_one` function of `my_crate`

Figure 14-1: add_one 함수에 대한 HTML 문서화

자주 사용되는 구절

우린 Listing 14-1 에서 HTML 에 "Examples." 제목을 가진 구절을 만들기 위해 # Examples 마크다운 헤더를 사용했습니다. 이외에 크레이트의 제작자가 일반적으로 문서에 사용하는 구절은 다음과 같습니다.

  • Panics: 문서화된 기능이 패닉을 일으킬 수 있는 시나리오입니다. 함수를 호출하는 사람들에게 "프로그램이 패닉을 일으키지 않게 하려면 이러한 상황에서는 이 함수를 호출하지 않아야 합니다" 라는 내용을 알려줍니다.
  • Errors: 해당 함수가 Result 를 반환할 경우에는 발생할 수 있는 에러의 종류와 해당 에러들이 발생하는 조건을 설명해 주어서 호출하는 사람이 여러 에러를 여러 방법으로 처리할 수 있도록 해야합니다.
  • Safety: 함수가 안전하지 않을(unsafe) 경우에 (19장에서 다루는 내용입니다) 왜 이 함수가 안전하지 않은지와 이 함수가 호출하는 사람에게 지키길 기대하는 불변성에 대해 알려주는 구절이 있어야 합니다.

대부분의 문서화 주석은 이 구절들이 모두 필요하진 않습니다. 하지만 여러분의 코드를 사용하는 사람들이 관심을 가지고 알아보게 될 측면에 대해 곱씹어 보게 만드는 좋은 체크리스트가 될 수 있습니다.

테스트로서의 문서화 주석

여러분의 문서화 주석에 예시 코드를 추가하는 건 여러분의 라이브러리를 어떻게 사용하는지 알려줄 수 있을뿐더러 또 다른 효과도 있습니다: 무려 cargo test 를 실행하면 여러분의 문서에 들어있던 예시 코드들이 테스트로서 실행됩니다! 백문이 불여일견이라는 말이 있듯이, 예시를 포함한 문서보다 좋은 문서는 없습니다. 다만, 코드를 변경하고 문서를 업데이트하지 않아서 예시 코드가 작동하지 않는 일은 절대 있어선 안되니 주의하세요. 우리가 Listing 14-1 의 add_one 함수에 대한 문서로 cargo test 를 실행하면 다음과 같은 테스트 결과를 보실수 있습니다.

   Doc-tests my_crate

running 1 test
test src/lib.rs - add_one (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

이제 우리가 함수나 예제를 변경하고 예시 코드에서 패닉이 발생하는 상태로 cargo test 를 실행하면, 문서 테스트 기능이 더이상 예시 코드가 기능하지 못한다고 알려줄 겁니다.

주석을 포함하는 항목을 문서화 하기

문서화 주석의 또 다른 스타일로 //! 가 있습니다. 이는 주석 뒤에 오는 항목을 문서화 하는게 아닌 주석을 포함하는 항목을 문서화 합니다. 일반적으로 크레이트의 루트 파일 (관례적으로 src/lib.rs 입니다) 이나 크레이트 혹은 모듈 전체를 문서화하는 모듈 내부에 이 문서화 주석을 작성합니다.

예시로, 만약 add_one 함수를 포함한 my_crate 크레이트를 설명하기 위한 목적으로 문서화를 진행한다면, Listing 14-2 처럼 src/lib.rs//! 로 시작하는 문서화 주석을 추가할 수 있습니다.

Filename: src/lib.rs

//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.

/// Adds one to the number given.
// --snip--

Listing 14-2: my_crate 크레이트 전체를 위한 문서화

//! 로 시작하는 줄 중 마지막 줄에 코드가 뒤따르지 않는다는 점을 주목하세요. 우린 주석 뒤에 따라오는 항목이 아닌, 주석을 포함하는 항목을 문서화 할 것이기에 /// 가 아니라 //! 로 시작하는 주석을 사용했습니다. 이 경우, 주석을 포함하는 항목은 크레이트의 루트 파일인 src/lib.rs 이며 주석은 전체 크레이트를 설명하게 됩니다.

cargo doc --open 을 실행하면, Figure 14-2 처럼 my_crate 문서 첫 페이지 내용 중 크레이트의 공개 아이템들 상단에 이 주석의 내용이 표시될 것입니다.

전체 크레이트를 위한 주석이 렌더링 된 HTML 문서

Figure 14-2: 전체 크레이트를 설명하는 주석이 포함된 my_crate 의 문서가 렌더링된 모습

항목 내 문서화 주석은 크레이트나 모듈을 설명하는데 유용합니다. 이를 이용해 사용자들이 크레이트의 구조를 이해할 수 있도록 크레이트의 중심 목적을 설명하세요.

pub use 를 이용해 공개 API 를 편리한 형태로 export 하기

7 장에서 우린 mod 키워드를 이용해 우리 코드를 체계화 하는 법과, pub 키워드로 공개 아이템을 만드는 법, use 를 이용해 스코프 내로 가져오는 법을 다뤘습니다. 다만 여러분이 크레이트를 개발할때 만들어놓은 구조는 여러분의 크레이트를 사용할 사용자들에게는 그다지 편리하지 않을 수 있습니다. 여러분은 여러 단계의 계층 구조를 이용해 크레이트를 구성하고 싶으시겠지만, 여러분이 계층 구조상에서 깊은곳에 정의한 타입을 다른 사람들이 사용하기에는 상당히 어려움을 겪을 수 있습니다. 애초에 그런 타입이 존재하는지 알아내는 것 조차 힘들테니까요. 또한 알아내더라도 use my_crate::UsefulType; 가 아니라 use my_crate::some_module::another_module::UsefulType; 를 입력 하는 일은 꽤나 짜증이 날 테죠.

공개 API 의 구조는 크레이트를 배포하는데 있어서 중요한 고려사항 중 하나입니다. 여러분의 크레이트를 이용할 사람들은 해당 구조에 있어서 여러분보다 이해도가 떨어질 것이고, 만약 여러분의 크레이트가 거대한 구조로 되어 있다면 자신들이 원하는 부분을 찾기조차 힘들 겁니다.

좋은 소식은 여러분이 만든 구조가 다른 라이브러리에서 이용하는데 편리하지 않다고 해서 굳이 내부 구조를 뒤엎을 필요는 없다는 겁니다. 대신에 여러분은 pub use 를 이용해 내부 항목을 다시 export(re-export) 하여 기존의 private 구조와 다른 public 구조를 만들 수 있다는 겁니다. 다시 export 한다는 것은 한 위치에서 공개 항목(public item)을 가져오고 이것을 마치 다른 위치에서 정의한 것처럼 공개 항목으로 만드는 것을 의미합니다.

예를 들어, 우리가 예술적인 개념을 모델링 하기 위해 art 라는 라이브러리를 만들었다고 가정해 봅시다. 해당 라이브러리에는 두 모듈이 들어 있습니다: kinds 모듈은 PrimaryColorSecondaryColor 열거형를 포함하고, utils 모듈은 mix 라는 이름의 함수를 포함합니다. Listing 14-3 처럼요.

Filename: src/lib.rs

//! # Art
//!
//! A library for modeling artistic concepts.

pub mod kinds {
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    use kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        // --생략--
    }
}

Listing 14-3: kinds 모듈과 utils 모듈로 이루어진 art 라이브러리

Figure 14-3 은 cargo doc 으로 생성된 이 크레이트 문서의 첫 화면입니다:

`kinds` 와 `utils` 모듈을 포함한 `art` 크레이트의 문서가 렌더링된 모습

Figure 14-3: kindsutils 모듈을 포함한 art 크레이트의 문서가 렌더링된 모습

PrimaryColor, SecondaryColor 타입들과 mix 함수가 첫 화면에 나오지 않는 걸 주목하세요. 이들을 보려면 각각 kindsutils 를 클릭하셔야 합니다.

이 라이브러리를 의존성으로 가지고 있는 다른 크레이트에서 use 를 이용해 art 의 항목을 가져오기 위해선, 현재 정의된 art 모듈의 구조대로 일일이 입력해야 합니다. Listing 14-4 에서 다른 크레이트에서 art 크레이트의 PrimaryColormix 를 이용하는 예시를 볼 수 있습니다.

Filename: src/main.rs

extern crate art;

use art::kinds::PrimaryColor;
use art::utils::mix;

fn main() {
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}

Listing 14-4: art 크레이트의 내부 구조에 정의된 항목을 이용하는 또 다른 크레이트

Listing 14-4 의 코드를 작성한, 즉 art 크레이트를 사용하는 사람은 PrimaryColorkinds 모듈에 들어있고 mixutils 모듈에 들어 있단 걸 알아내야 합니다. 이처럼 현재 art 크레이트의 구조는 크레이트를 사용하는 사람보다 크레이트를 개발하는 사람에게 적합한 구조로 되어 있습니다. 내부 구조상에서의 kindsutils 모듈의 위치 같은 정보는 art 크레이트를 사용하는 입장에서는 전혀 필요 없는 정보이며, 또한 직접 구조상에서 자신이 찾는 것의 위치를 알아내야 하고 use 뒤에 모듈의 이름을 일일이 입력해야 한다는 건 혼란스럽고 불편한 일 이니까요.

공개 API 로부터 내부 구조의 흔적를 제거하려면 Listing 14-3 처럼 맨 위에서 pub use 를 이용해 다시 export 하도록 art 크레이트의 코드를 수정해야 합니다:

Filename: src/lib.rs

//! # Art
//!
//! A library for modeling artistic concepts.

pub use kinds::PrimaryColor;
pub use kinds::SecondaryColor;
pub use utils::mix;

pub mod kinds {
    // --snip--
}

pub mod utils {
    // --snip--
}

Listing 14-5: Re-export 를 위해 pub use 추가

cargo doc 를 이용해 현재 크레이트에 대한 API 문서를 생성하면 Figure 14-4 처럼 Re-exports 목록과 링크가 첫 페이지에 나타날 겁니다. 이로써 PrimaryColor, Secondary 타입과 mix 함수를 훨씬 더 쉽게 찾을 수 있게 되었네요.

첫 페이지에 Re-exports 목록이 포함된 `art` 크레이트의 문서

Figure 14-4: Re-exports 목록이 포함된 art 크레이트 문서의 첫 페이지

art 크레이트의 사용자는 기존의 Listing 14-3 의 내부 구조를 이용하여 Listing 14-4 처럼 사용하거나, 혹은 좀 더 편한 방식으로 Listing 14-5 의 구조를 이용하여 Listing 14-6 과 같이 사용할 수 있습니다:

Filename: src/main.rs

extern crate art;

use art::PrimaryColor;
use art::mix;

fn main() {
    // --생략--
}

Listing 14-6: art 크레이트의 Re-export 된 항목들을 사용하는 프로그램

만약 특정 부분에서 중첩된 모듈이 많을 경우, 모듈의 상위 계층에서 pub use 를 이용해 타입을 다시 export 함으로써 크레이트의 사용자들에게 더 뛰어난 경험을 제공할 수 있습니다.

쓰기 좋고 편한 형태의 공개 API 를 만드는 일은 기술보단 예술에 가까운 일입니다. 따라서 한번에 완벽한 형태를 만들려고 하기보다는 계속해서 사용자들을 위한 최적의 구조를 찾아 개선해 나가야 합니다. 이럴때 pub use 를 이용하면 크레이트 내부를 보다 유연하게 구조화 할 수 있고, 사용자에게 제공하는 것에서 내부 구조의 흔적을 없앨 수 있습니다. 한번 여러분이 설치한 크레이트 중에 아무거나 코드를 열어서 그의 공개 API 구조와 내부 구조를 비교해 보세요. 아마 상당히 다를걸요?

Cartes.io 계정 설정하기

여러분은 첫 크레이트를 배포하기에 앞서, crates.io 에 계정을 만들고 API 토큰을 얻어야 합니다. crates.io 홈페이지에 방문하고 GitHub 계정을 통해 로그인 해주세요. (현재는 GitHub 계정이 필수지만, 추후에 사이트에서 다른 방법을 통한 계정 생성을 지원하게 될 수 있습니다) 로그인 하셨다면 계정 설정 페이지인https://crates.io/me/ 로 들어가 주세요. 그리고 페이지에서 API 키를 얻어온 후에, 여러분의 API 키를 이용해 cargo login 명령어를 실행해 주세요. 이런식으로요:

$ cargo login abcdefghijklmnopqrstuvwxyz012345

이 명령어는 Cargo 에게 여러분의 API 토큰을 알려주고 내부 (~/.cargo/credentials) 에 저장하도록 합니다. 미리 말하지만 여러분의 토큰은 남들에겐 비밀 입니다: 어떤 이유로 남들에게 알려졌다면, (그 사람을 처리하거나, 혹은) crates.io 에서 기존의 토큰을 무효화하고 새 토큰을 발급받으세요.

새 크레이트에 Metadata 추가하기

계정을 만들었으니, 여러분이 크레이트를 배포하려고 한다고 가정합시다. 여러분은 배포하기 전에 Cargo.toml 파일에 [package] 구절을 추가하여 메타데이터(metadata) 를 추가해야합니다.

여러분의 크레이트명은 고유해야 합니다. 여러분이 로컬에서 작업 할 땐 문제 없지만, crates.io 에 올라갈 크레이트의 이름은 선착순으로 배정되기에, 여러분이 정한 크레이트명을 누군가 이미 쓰고 있다면 해당 크레이트명으로는 크레이트를 배포할 수 없습니다. 크레이트를 배포하기 전에 사이트에서 여러분이 사용하려는 이름을 검색해보고 해당 크레이트명이 이미 사용중인지 확인하세요. 만약 아직 사용중이지 않다면 다음과 같이 Cargo.toml 파일 내 [package] 절 아래의 이름을 수정하세요:

Filename: Cargo.toml

[package]
name = "guessing_game"

고유한 이름을 선택하고, 크레이트를 배포하기 위해 cargo publish 를 실행하면 다음과 같은 경고와 에러가 나타날 겁니다.

$ cargo publish
    Updating registry `https://github.com/rust-lang/crates.io-index`
warning: manifest has no description, license, license-file, documentation,
homepage or repository.
--snip--
error: api errors: missing or empty metadata fields: description, license.

이 에러는 중요한 정보를 몇개 입력하지 않았다는 의미입니다: 설명(description) 과 라이센스(license) 는 필수적인데, 이들은 각각 사람들에게 해당 크레이트가 어떤 작업을 하는지와 해당 크레이트를 이용할 수 있는 조건을 알려줍니다. 이 에러를 고치려면 이 정보들을 Cargo.toml 에 포함시켜야 합니다.

설명은 한 문장이나 두 문장정도면 충분합니다. 크레이트를 검색 했을때의 결과에 여러분의 크레이트명과 같이 표시되거든요. license 필드엔 라이센스 식별자 값(license identifier value) 을 부여해야 합니다. Linux Foundation’s Software Package Data Exchange (SPDX) 에 여러분이 사용할 수 있는 식별자가 나열되어 있으니 참고 바랍니다. 예를 들어, 만약 여러분의 크레이트에 MIT 라이센스를 적용하고 싶으시다면, 다음과 같이 MIT 식별자를 추가하시면 됩니다.

Filename: Cargo.toml

[package]
name = "guessing_game"
license = "MIT"

SPDX 에 없는 라이센스를 사용하고 싶으실 경우엔 해당 라이센스의 텍스트를 파일로 만들고 자신의 프로젝트에 해당 파일을 포함시킨 뒤, license 대신 license-file 을 추가해 해당 파일의 이름을 넣으시면 됩니다.

여러분의 프로젝트에 어떤 라이센스가 적합한지에 대해 알아보는 내용은 이 책 범위 이상의 내용입니다. 다만 알아두실 건 러스트 커뮤니티의 많은 이들은 자신의 프로젝트에 러스트 자체가 쓰는 라이센스인 MIT OR Apache-2.0 이중 라이센스를 사용한다는 겁니다, 즉 여러분은 프로젝트의 라이선스에 OR 을 이용해 여러 라이센스 식별자를 명시할 수 있습니다.

고유한 프로젝트명, 버전, cargo new 로 크레이트를 생성할때 추가된 작성자 정보, 설명, 라이센스를 모두 추가하셨다면 배포할 준비가 끝났습니다. 이때 Cargo.toml 파일의 모습은 다음과 같은 형태일 겁니다:

Filename: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
description = "A fun game where you guess what number the computer has chosen."
license = "MIT OR Apache-2.0"

[dependencies]

Cargo 공식 문서 에 다른 사람들이 여러분의 크레이트를 좀 더 찾기 쉽게 해주고, 쓰기 편하게 해주는 나머지 메타데이터들이 설명 되어 있으니, 참고 바랍니다.

Crates.io 에 배포하기

계정도 만들었고, API 토큰도 얻었고, 크레이트명도 정했고, 메타데이터도 작성했으니 이제 여러분은 크레이트를 배포할 준비 만전이에요! 크레이트를 배포한다는 것은 다른 사람이 사용할 특정 버전을 crates.io 에 올리는 것입니다.

크레이트를 배포할땐 주의하시기 바랍니다. 기본적으로 낙장불입이거든요. 버전은 중복될 수 없으며, 한번 올라간 코드는 수정할 수 없습니다. crates.io 의 원대한 목표중 하나는 crates.io 에 등록된 크레이트들에 의존하는 모든 프로젝트의 빌드가 계속 작동할 수 있도록 영구적인 코드 보관소의 역할을 맡는 것이기 때문에, 버전을 삭제하거나 수정하는 행위는 용납하지 않습니다. 만약 용납한다면 목표를 이룰 수 없으니까요. 대신 버전의 개수에 대한 제한은 없으니 버전을 올리는 것 자체는 얼마든지 가능합니다.

cargo publish 명령어를 재실행 해보면 이번엔 성공할 겁니다:

$ cargo publish
 Updating registry `https://github.com/rust-lang/crates.io-index`
Packaging guessing_game v0.1.0 (file:///projects/guessing_game)
Verifying guessing_game v0.1.0 (file:///projects/guessing_game)
Compiling guessing_game v0.1.0
(file:///projects/guessing_game/target/package/guessing_game-0.1.0)
 Finished dev [unoptimized + debuginfo] target(s) in 0.19 secs
Uploading guessing_game v0.1.0 (file:///projects/guessing_game)

축하합니다! 이제 여러분의 코드는 러스트 커뮤니티와 공유되고, 아무나 여러분의 크레이트를 자신들의 프로젝트 의존성 목록에 쉽게 추가할 수 있을 겁니다.

이미 배포한 크레이트의 버전 업데이트하기

여러분의 크레이트에 변경사항을 적용하고 새 버전을 릴리즈하려면 Cargo.toml 파일의 version 값을 새 버전으로 변경하면 됩니다. 이때 변경사항의 종류에 맞춰서 적절한 버전을 결정하는 방법은 유의적 버전 규칙(Semantic Versioning rules) 을 참고하시기 바랍니다. 버전을 변경하고 나면 cargo publish 를 실행해 새 버전을 배포합시다.

cargo yank 를 이용해 Crates.io 에서 버전 제거하기

크레이트의 이전 버전을 제거할 순 없지만, Cargo 는 크레이트의 버전을 yanking(끌어내리는) 기능을 지원합니다. 이는 특정 크레이트의 버전이 어떤 이유에선가 문제가 생긴 등의 경우에 새롭게 만들어지는 프로젝트들이 해당 버전을 종속성으로 추가할 수 없도록 막아주는 주는 기능입니다. (역주: yank 의 사전적 의미는 홱 당기다 입니다)

버전을 끌어내려도 해당 버전에 의존하던 기존의 프로젝트들은 계속해서 그 버전에 의존성을 가질 수 있고 해당 버전을 다운로드 받을 수도 있지만, 새로운 프로젝트들이 끌어내려진 버전을 의존성으로 가지는 시작하는것은 불가능합니다. 근본적인 yank 의 의미는 Cargo.lock 을 가진 모든 프로젝트는 문제가 없을 것이며, 추후에 새로 생성될 Cargo.lock 파일은 끌어내려진 버전을 사용하지 않을 것이란 의미입니다.

크레이트의 버전을 yank 하기 위해서는 cargo yank 에 yank 하고자 하는 버전을 명시하고 실행하시면 됩니다:

$ cargo yank --vers 1.0.1

또한 여러분은 --undo 를 붙여서 yank 를 취소하고 다시 새 프로젝트들이 해당 버전을 의존성으로 갖는 것을 허용할 수 있습니다:

$ cargo yank --vers 1.0.1 --undo

yank 는 어떤 코드도 삭제하지 않습니다. 예를 들어, 여러분이 실수로 자신의 비밀 정보를 업로드한 상황에 대한 해결책으로 yank 기능을 사용하셨다면, 이는 잘못된 방법입니다. 만약 그런 일이 일어나면 비밀 정보를 재설정하셔야 합니다.

Cargo 작업공간

12 장에서 바이너리 크레이트와 라이브러리 크레이트를 포함하는 패키지를 만들어 봤습니다. 하지만 여러분이 프로젝트를 개발하다 보면, 라이브러리 크레이트가 점점 거대해져서 여러분의 패키지를 여러개의 라이브러리 크레이트로 분리하고 싶으실 겁니다. Cargo 는 이런 상황에서 사용할 수 있는 작업공간(workspace) 이라는 기능을 제공하며, 이 기능은 함께 개발된 여러개의 관련된 패키지를 관리하는데 도움이 됩니다.

작업공간 생성

작업공간(workspace) 은 동일한 Cargo.lock 과 출력 디렉토리를 공유하는 패키지들의 집합입니다. 한번 이 작업공간을 이용한 프로젝트를 만들어 봅시다. 다만 작업공간의 구조에 집중할 수 있도록 간단한 코드만 사용할 겁니다. 작업공간을 구성하는 방법은 여러가지가 있지만, 일반적인 방법중 하나를 사용하도록 하겠습니다; 작업 공간은 바이너리 하나와 두 라이브러리를 포함하도록 할 것입니다. 주요 기능을 제공할 바이너리는 두 라이브러리를 의존성으로 가지게 될 것인데, 하나는 add_one 함수를 제공할 것이고, 또 하나는 add_two 함수를 제공할 것입니다. 이 세 크레이트는 같은 작업 공간의 일부가 될 겁니다. 그럼 작업공간을 위한 새 디렉토리를 만드는 것 부터 시작합시다.

$ mkdir add
$ cd add

다음은 add 디렉토리 내에서 전체 작업공간을 구성 할 Cargo.toml 파일을 생성합시다. 이 파일은 우리가 여태 다른곳에서 봐온 Cargo.toml 파일들과는 달리, [package] 절이나 메타데이터를 가지지 않습니다. 대신 [workspace] 로 시작하는 구절을 갖는데, 이걸 이용해 작업공간에 members 를 추가할 수 있습니다; 추가하는 법은 우리의 바이너리 크레이트 경로를 명시하는 것이며, 이 경우 해당 경로는 adder 입니다:

Filename: Cargo.toml

[workspace]

members = [
    "adder",
]

다음으로, add 디렉토리 안에서 cargo new 를 실행하여 adder 바이너리 크레이트를 생성합시다:

$ cargo new --bin adder
     Created binary (application) `adder` project

이 시점에서 우린 작업 공간을 cargo build 로 빌드할 수 있습니다. 현재 여러분의 add 디렉토리의 내부 모습은 다음과 같은 형태여야 하니, 비교해 보시기 바랍니다:

├── Cargo.lock
├── Cargo.toml
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

작업공간은 최상위 디렉토리에 컴파일된 결과를 배치하기 위한 하나의 target 디렉토리를 가집니다; 따라서 adder 크레이트는 자신만의 target 디렉토리를 갖지 않습니다. 만약 adder 디렉토리 내에서 cargo build 명령어를 실행하더라도 컴파일 결과는 add/adder/target 이 아닌 add/target 에 위치하게 될 겁니다. Cargo 가 작업공간 내에 이와 같이 target 디렉토리를 구성한 이유는, 작업공간 내의 크레이트들이 서로 의존하기로 되어있기 때문입니다. 만약 각 크레이트가 각각의 target 디렉토리를 갖게 된다면, 각각의 크레이트를 컴파일 할때마다 자신의 target 디렉토리에 컴파일 결과를 넣기 위해 다른 크레이트들을 매번 재컴파일 하게 될 겁니다. 이와 같은 불필요한 재빌드를 피하기 위해, 하나의 크레이트들은 target 디렉토리를 공유하도록 되어 있습니다.

작업공간에 두번째 크레이트 만들기

다음은 작업공간에 add-one 이라고 부를 새로운 멤버 크레이트를 생성해 봅시다. members 목록에 add-one 경로를 지정하기 위해 최상위의 Cargo.toml 파일을 수정합시다.

Filename: Cargo.toml

[workspace]

members = [
    "adder",
    "add-one",
]

그리고 add-one 이라는 새 라이브러리 크레이트를 생성합시다.

$ cargo new add-one
     Created library `add-one` project

이제 여러분의 add 디렉토리는 다음과 같은 디렉토리와 파일들을 갖게 될 겁니다:

├── Cargo.lock
├── Cargo.toml
├── add-one
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

add-one/src/lib.rs 파일에 add_one 함수를 추가합시다:

Filename: add-one/src/lib.rs


# #![allow(unused_variables)]
#fn main() {
pub fn add_one(x: i32) -> i32 {
    x + 1
}
#}

이제 우린 작업공간 내에 라이브러리 크레이트를 가졌으니, adder 바이너리 크레이트를 add-one 라이브러리 크레이트에 의존하도록 만들 수 있습니다. 먼저, adder/Cargo.tomladd-one 에 대한 의존성 경로를 추가합시다.

Filename: adder/Cargo.toml

[dependencies]

add-one = { path = "../add-one" }

Cargo 는 작업공간 내 크레이트들이 서로 의존하고 있을 것이라고 추정하지 않기 때문에, 우리가 크레이트간의 의존 관계에 대해 명시해 주어야 합니다.

다음으로 adder 크레이트에서 add-one 크레이트의 add_one 함수를 사용해보도록 합시다. adder/src/main.rs 파일을 열고 상단에 extern crate 행을 추가해 스코프 내로 add-one 라이브러리를 가져오도록 한 뒤, main 함수를 add_one 함수를 호출하도록 변경합니다. Listing 14-7 처럼요:

Filename: adder/src/main.rs

extern crate add_one;

fn main() {
    let num = 10;
    println!("Hello, world! {} plus one is {}!", num, add_one::add_one(num));
}

Listing 14-7: adder 크레이트에서 add-one 라이브러리 사용하기

이제 한번 최상위 add 디렉토리에서 cargo build 를 실행해 작업공간을 빌드해 봅시다!

$ cargo build
   Compiling add-one v0.1.0 (file:///projects/add/add-one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.68 secs

add 디렉토리에서 바이너리 크레이트를 실행하기 위해선 cargo run-p 옵션과 패키지 이름을 사용하여 우리가 작업공간 내에서 사용할 패키지를 명시해야 합니다:

$ cargo run -p adder
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/adder`
Hello, world! 10 plus one is 11!

이는 add-one 크레이트에 의존성을 가진 adder/src/main.rs 코드를 실행시킵니다.

작업공간의 외부 크레이트에 의존성 갖기

작업공간은 작업공간에 있는 각각의 크레이트의 디렉토리에 Cargo.lock 파일을 갖는게 아닌, 작업공간의 최상위에만 단 하나의 Cargo.lock 파일을 갖는다는 걸 기억하세요. 이는 모든 크레이트들이 모든 의존성의 같은 버전을 사용함을 보증합니다. 만약 우리가 rand 크레이트를 adder/Cargo.tomladd-one/Cargo.toml 에 추가하면 Cargo 는 둘을 모두 같은 버전을 쓰도록 결정하고 하나의 Cargo.lock 에 기록합니다. 작업공간의 모든 크레이트들이 같은 의존성을 갖도록 한다는 의미는 작업공간 내의 크레이트들이 항상 서로 조화를 이룬다는 의미입니다. 한번 add-one 크레이트에서 rand 크레이트를 사용할 수 있도록 add-one/Cargo.toml 파일의 [dependencies] 절에 rand 를 추가해 봅시다:

Filename: add-one/Cargo.toml

[dependencies]

rand = "0.3.14"

이제 우린 add-one/src/lib.rs 파일에 extern crate rand; 를 추가할 수 있으며, add 디렉토리에서 cargo build 를 이용해 전체 작업공간을 빌드하면 rand 크레이트를 가져오고 컴파일 할 것입니다:

$ cargo build
    Updating registry `https://github.com/rust-lang/crates.io-index`
 Downloading rand v0.3.14
   --snip--
   Compiling rand v0.3.14
   Compiling add-one v0.1.0 (file:///projects/add/add-one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 10.18 secs

이제 최상위 Cargo.lockadd-onerand 로의 종속성 정보가 포함되어 있습니다. 하지만, 작업공간의 어딘가에서 rand 를 사용하였다고 해도, 작업공간의 다른 크레이트에선 rand 를 자신의 Cargo.toml 파일에 추가하지 않는 한 사용이 불가능합니다. 예를 들어, 만약 adder 크레이트에서 rand 를 그냥 사용하기 위해 adder/src/main.rs 파일에 extern crate rand; 를 추가하면 에러가 나타납니다:

$ cargo build
   Compiling adder v0.1.0 (file:///projects/add/adder)
error: use of unstable library feature 'rand': use `rand` from crates.io (see
issue #27703)
 --> adder/src/main.rs:1:1
  |
1 | extern crate rand;

이 에러를 해결하려면 adder 크레이트의 Cargo.toml 파일을 수정하여 rand 를 해당 크레이트의 의존성으로 나타내야합니다. 그 후 adder 크레이트를 빌드하면 Cargo.lockadder 을 위한 의존성 목록에 rand 가 추가될 테지만, rand 가 다시 다운로드 되진 않을 겁니다. Cargo 는 rand 를 사용하는 작업공간 내의 크레이트는 모두 같은 버전의 rand 크레이트를 사용할 것임을 보장하기 때문에 같은 크레이트를 여러개의 버전으로 다운로드 받을 필요 없고, 따라서 그만큼 공간은 절약되며, 작업공간 내의 각 크레이트는 조화를 이룰 수 있습니다.

작업공간에 테스트 추가하기

또 다른 향상을 위해, add_one 크레이트의 add_one::add_one 함수에 대한 테스트를 추가해 봅시다.

Filename: add-one/src/lib.rs


# #![allow(unused_variables)]
#fn main() {
pub fn add_one(x: i32) -> i32 {
    x + 1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        assert_eq!(3, add_one(2));
    }
}
#}

이제 최상위 add 디렉토리에서 cargo test 를 실행해 봅시다:

$ cargo test
   Compiling add-one v0.1.0 (file:///projects/add/add-one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27 secs
     Running target/debug/deps/add_one-f0253159197f7841

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/adder-f88af9d2cc175a5e

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests add-one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

출력의 첫번째 절 은 add-one 크레이트의 it_works 테스트가 통과했다는 의미이고, 다음 절 은 adder 크레이트에서 테스트를 찾지 못했다는 의미이며, 마지막 절 은 add-one 크레이트에서 문서화 테스트를 찾지 못했다는 의미입니다. 이처럼 작업공간 구조 내에서 cargo test 를 실행하면 작업공간 내의 모든 크레이트에 대한 테스트들이 실행됩니다.

우린 작업공간 내의 하나의 특정한 크레이트에 대한 테스트도 실행할 수 있습니다. 최상위 디렉토리에서 -p 플래그와 테스트 하고자 하는 크레이트명을 명시해줌으로써 말이죠:

$ cargo test -p add-one
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/add_one-b3235fea9a156f74

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests add-one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

이 출력은 cargo testadder 크레이트는 테스트하지 않고 add-one 크레이트에 대해서만 테스트를 실행 했음을 보여줍니다.

만약 여러분이 https://crates.io/ 에 작업공간 내의 크레이트를 배포하시려면, 각 크레이트는 분리돼서 배포되어야 합니다. cargo publish 명령어엔 --all 이나 -p 같은 플래그가 없어요. 따라서 여러분은 각 크레이트 디렉토리를 수정하고 cargo publish 를 실행해야 합니다.

추가 과제로는, 한번 이 작업공간에 add-two 크레이트를 추가해 보세요! add-one 크레이트를 추가할때와 비슷한 방법으로 하시면 됩니다.

언젠가 여러분의 프로젝트가 커지면 작업공간을 사용하는 걸 고려해보세요: 하나의 거대한 코드보다 작은 개별 요소를 이해하는 일이 훨씬 쉽고, 작업공간에서 크레이트를 관리한다면 각 크레이트가 동시에 변경되는 경우도 쉽게 조정할 수 있습니다.

cargo install 을 이용해 Crates.io 에서 바이너리 설치하기

cargo install 명령어는 여러분이 로컬에서 바이너리 크레이트를 설치하고 사용할 수 있도록 해줍니다. 이는 시스템 패키지를 대체하기 위한 것이 아닌, 러스트 개발자들이 crates.io 에서 공유하고 있는 툴을 편리하게 설치할 수 있도록 하기 위함입니다. 여러분은 바이너리 타겟(binary target) 을 가진 패키지만 설치할 수 있다는 걸 알아두셔야 하는데, 이 바이너리 타겟 이란 혼자서 실행될 수 없고 다른 프로그램에 포함되는 용도인 라이브러리 타겟과는 반대되는 의미로, src/main.rs 파일 혹은 따로 바이너리로 지정된 파일을 가진 크레이트가 생성해낸 실행 가능한 프로그램을 말합니다. 보통 해당 크레이트가 라이브러리인지, 바이너리 타겟을 갖는지, 혹은 둘 다인지에 대한 정보를 README 파일에 작성해둡니다.

cargo install 을 이용해 설치한 모든 바이너리들은 Cargo가 설치된 폴더의 bin 폴더에 저장됩니다. 만약 여러분이 rustup.rs 를 이용해 러스트를 설치하셨고, 따로 설정을 건들지 않으셨다면 $HOME/.cargo/bin 폴더입니다. cargo install 로 설치한 프로그램을 실행하시려면 여러분의 $PATH 환경변수에 해당 디렉토리가 등록되어 있는지 확인하세요.

12 장에서 언급한 grep 을 러스트로 구현한 파일 검색 툴인 ripgrep 을 예로 들어봅시다. ripgrep 을 설치하려면 다음과 같이 하면 됩니다:

$ cargo install ripgrep
Updating registry `https://github.com/rust-lang/crates.io-index`
 Downloading ripgrep v0.3.2
 --snip--
   Compiling ripgrep v0.3.2
    Finished release [optimized + debuginfo] target(s) in 97.91 secs
  Installing ~/.cargo/bin/rg

출력의 마지막 줄은 설치된 바이너리의 경로와 이름을 보여줍니다. ripgrep 의 이름은 rg 네요. 방금 앞에서 말했던 것처럼 여러분의 $PATH 환경변수에 설치된 폴더가 등록되어 있는 한, 여러분은 명령창에서 rg --help 를 실행할 수 있고, 앞으로 파일을 찾을때 더 빠르고 러스트다운 툴을 사용할 수 있습니다!

커스텀 명령어로 Cargo 확장하기

Cargo 는 여러분이 직접 Cargo 를 수정하지 않고도 새 보조 명령어를 추가할 수 있도록 되어 있습니다. 만약 여러분의 $PATH 내 어떤 바이너리의 이름이 cargo-something 이고, 해당 바이너리가 Cargo 의 보조 명령어 바이너리일 경우 cargo something 라는 명령어를 이용해 실행할 수 있습니다. 이와 같은 커스텀 명령어들은 cargo --list 를 실행 할 때의 목록에도 포함됩니다. 이런식으로 cargo install 을 이용해 확장 모듈을 설치하고 Cargo 의 자체 툴처럼 이용할 수 있다는 점은 Cargo 의 무척 편리한 점 중 하나입니다.

정리

Cargo 와 crates.io 를 통해 코드를 공유하는 행위는 러스트 생태계를 발전시키고, 러스트가 많은 방면에서 활약하도록 만드는데 주축이 되는 행위입니다. 러스트의 기본 라이브러리는 작고 고정되어 있지만, 크레이트들은 쉽게 공유될 수 있고, 쉽게 사용될 수 있으며 러스트 언어 자체보다 훨씬 빠른 속도로 발전합니다. 여러분에게 유용한 코드가 있다면 주저말고 crates.io 에 공유하세요; 분명 다른 누군가에게도 도움이 될 테니까요!

스마트 포인터

포인터 (pointer) 는 메모리의 주소 값을 담고 있는 변수에 대한 일반적인 개념입니다. 이 주소 값은 어떤 다른 데이터를 참조합니다. 혹은 바꿔 말하면, “가리킵니다”. 러스트에서 가장 흔한 종류의 포인터는 참조자인데, 이는 여러분들이 3장에서 배웠던 것입니다. 참조자는 & 심볼에 의해 나타내지고 이들이 가리키고 있는 값을 빌립니다. 이들은 값을 참조하는 것 외에 다른 어떤 특별한 능력도 없습니다. 또한, 이들은 어떠한 오버헤드도 발생하지 않으며 우리가 가장 자주 사용하는 포인터의 한 종류입니다.

한편, 스마트 포인터 (smart pointer) 는 포인터처럼 작동하지만 추가적인 메타데이터와 능력들도 가지고 있는 데이터 구조입니다. 스마트 포인터의 개념은 러스트에 고유한 것이 아닙니다: 스마트 포인터는 C++로부터 유래되었고 또한 다른 언어들에도 존재합니다. 러스트에서는, 표준 라이브러리에 정의된 다양한 종류의 스마트 포인터들이 참조자들에 의해 제공되는 것을 넘어서는 추가 기능을 제공합니다. 우리가 이번 장에서 탐구할 한 가지 예로는 참조 카운팅 (reference counting) 스마트 포인터 타입이 있습니다. 이 포인터는 소유자의 수를 계속 추적하고, 더 이상 소유자가 없으면 데이터를 정리하는 방식으로, 여러분들이 어떤 데이터에 대한 여러 소유자들을 만들 수 있게 해 줍니다.

소유권과 빌림의 개념을 가지고 있는 러스트에서, 참조자와 스마트 포인터 간의 추가적인 차이점은 참조자가 데이터를 오직 빌리기만 하는 포인터라는 점입니다; 반면, 많은 경우에서 스마트 포인터는 그들이 가리키고 있는 데이터를 소유합니다.

우리는 이미 이 책에서 8장의 StringVec<T>와 같은 몇 가지 스마트 포인터들을 마주쳤습니다. 비록 그때는 이것들을 스마트 포인터라고 부르지 않았지만요. 이 두 타입 모두 스마트 포인터로 치는데 그 이유는 이들이 얼마간의 메모리를 소유하고 여러분이 이를 다루도록 허용하기 때문입니다. 그들은 또한 (그들의 용량 등의) 메타데이터와 (String이 언제나 유효한 UTF-8일 것임을 보장하는 것 등의) 추가 능력 혹은 보장을 갖고 있습니다.

스마트 포인터는 보통 구조체를 이용하여 구현되어 있습니다. 스마트 포인터가 일반적인 구조체와 구분되는 특성은 바로 스마트 포인터가 DerefDrop 트레잇을 구현한다는 것입니다. Deref 트레잇은 스마트 포인터 구조체의 인스턴스가 참조자처럼 동작하도록 하여 참조자나 스마트 포인터 둘 중 하나와 함께 작동하는 코드를 작성하게 해 줍니다. Drop 트레잇은 스마트 포인터의 인스턴스가 스코프 밖으로 벗어났을 때 실행되는 코드를 커스터마이징 가능하도록 해 줍니다. 이번 장에서는 이 두 개의 트레잇 모두를 다루고 이들이 어째서 스마트 포인터에게 중요한지를 보여줄 것입니다.

스마트 포인터 패턴이 러스트에서 자주 사용되는 일반적인 디자인 패턴으로 주어지므로, 이번 장에서는 존재하는 스마트 포인터를 모두 다루지는 않을 것입니다. 많은 라이브러리들이 그들 자신만의 스마트 포인터를 가지고 있고, 심지어 여러분도 여러분 자신만의 것을 작성할 수 있습니다. 우리는 표준 라이브러리 내의 가장 흔한 스마트 포인터들을 다룰 것입니다:

  • 값을 힙에 할당하기 위한 Box<T>
  • 복수개의 소유권을 가능하게 하는 참조 카운팅 타입인 Rc<T>
  • 빌림 규칙을 컴파일 타임 대신 런타임에 강제하는 타입인, RefCell<T>를 통해 접근 가능한 Ref<T>RefMut<T>

추가로, 우리는 불변 타입이 내부 값을 변경하기 위하여 API를 노출하는 내부 가변성 (interior mutability) 패턴에 대해 다룰 것입니다. 또한 *참조 순환 (reference cycles)*이 어떤 식으로 메모리가 세어나가게 할 수 있으며, 이를 어떻게 방지하는지에 대해서도 논의해 보겠습니다.

함께 뛰어들어 볼까요!

Box<T>는 힙에 있는 데이터를 가리키고 알려진 크기를 갖습니다

가장 직관적인 스마트 포인터는 박스 (box) 인데, 이 타입은 Box<T> 라고 쓰입니다. 박스는 여러분이 데이터를 스택이 아니라 힙에 저장할 수 있도록 해줍니다. 스택에 남는 것은 힙 데이터를 가리키는 포인터입니다. 스택과 힙의 차이를 상기하려면 4장을 참조하세요.

박스는 스택 대신 힙에 데이터를 저장한다는 점 외에는, 성능적인 오버헤드가 없습니다. 하지만 여러 가지의 추가 기능 또한 가지고 있지 않습니다. 여러분은 이를 아래와 같은 상황에서 가장 자주 쓰게 될 것입니다:

  • 컴파일 타임에 크기를 알 수 없는 타입을 갖고 있고, 정확한 사이즈를 알 필요가 있는 맥락 안에서 해당 타입의 값을 이용하고 싶을 때
  • 커다란 데이터를 가지고 있고 소유권을 옮기고 싶지만 그렇게 했을 때 데이터가 복사되지 않을 것이라고 보장하기를 원할 때
  • 어떤 값을 소유하고 이 값의 구체화된 타입을 알고 있기보다는 특정 트레잇을 구현한 타입이라는 점만 신경 쓰고 싶을 때

이 장에서는 첫 번째 상황을 보여줄 것입니다. 그러나 보여주기 전에, 나머지 두 상황에 대해 약간 더 자세히 말하겠습니다: 두 번째 경우, 방대한 양의 데이터의 소유권 옮기기는 긴 시간이 소요될 수 있는데 이는 그 데이터가 스택 상에서 복사되기 때문입니다. 이러한 상황에서 성능을 향상하기 위해서, 박스 안의 힙에 그 방대한 양의 데이터를 저장할 수 있습니다. 그러면, 작은 양의 포인터 데이터만 스택 상에서 복사되고, 데이터는 힙 상에서 한 곳에 머물게 됩니다. 세 번째 경우는 트레잇 객체 (trait object) 라고 알려진 것이고, 17장이 이 주제만으로 전체를 쏟아부었습니다. 그러니 여러분이 여기서 배운 것을 17장에서 다시 적용하게 될 것입니다!

Box<T>을 사용하여 힙에 데이터를 저장하기

Box<T>에 대한 사용례를 논의하기 전에, 먼저 문법 및 Box<T> 내에 저장된 값과 어떻게 상호작용 하는지 다루겠습니다.

Listing 15-1은 힙에 i32 값을 저장하기 위해 박스를 사용하는 법을 보여줍니다:

Filename: src/main.rs

fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}

Listing 15-1: 박스를 사용하여 i32 값을 힙에 저장하기

5라는 값을 가리키는 Box의 값을 갖는 변수 b를 선언했는데, 여기서 5는 힙에 할당됩니다. 이 프로그램은 b = 5를 출력할 것입니다; 이 경우, 우리는 마치 이 데이터가 스택에 있었던 것과 유사한 방식으로 박스 내의 데이터에 접근할 수 있습니다. 다른 어떤 소유한 값과 마찬가지로, bmain의 끝에 도달하는 것처럼 어떤 박스가 스코프를 벗어날 때, 할당은 해제될 것입니다. 할당 해제는 (스택에 저장된) 박스와 이것이 가리키고 있는 (힙에 저장된) 데이터 모두에게 일어납니다.

단일 값을 힙에 집어넣는 것은 그다지 유용하지는 않으므로, 이 방식처럼 박스를 이용하는 것은 자주 쓰지 않을 것입니다. 단일한 i32 같은 값을 스택에 갖는 것은, 스택이 해당 값이 기본적으로 저장되는 곳이기도 하고, 대부분의 경우에서 더 적절합니다. 만일 우리가 박스를 쓰지 않는다면 허용되지 않았을 타입을 정의하도록 해주는 경우를 살펴봅시다.

박스는 재귀적 타입을 가능하게 합니다

컴파일 타임에서, 러스트는 어떤 타입이 얼마나 많은 공간을 차지하는지를 알 필요가 있습니다. 컴파일 타임에는 크기를 알 수 없는 한 가지 타입이 바로 재귀적 타입 (recursive type) 인데, 이는 어떤 값이 그 일부로서 동일한 타입의 다른 값을 가질 수 있는 것을 말합니다. 이러한 값의 내포가 이론적으로는 무한하게 계속될 수 있으므로, 러스트는 재귀적 타입의 값이 얼마큼의 공간을 필요로 하는지 알지 못합니다. 하지만, 박스는 알려진 크기를 갖고 있으므로, 재귀적 타입 정의 내에 박스를 넣음으로써 이를 쓸 수 있습니다.

재귀적 타입의 예제로서, 함수형 프로그래밍 언어에서 일반적인 데이터 타입인 cons list를 탐험해 봅시다. 우리가 정의할 cons list 타입은 재귀를 제외하면 직관적입니다; 그러므로, 우리가 작업할 예제에서의 개념은 여러분이 재귀적 타입을 포함하는 더 복잡한 어떠한 경우에 처하더라도 유용할 것입니다.

Cons List에 대한 더 많은 정보

cons list는 Lisp 프로그래밍 언어 및 그의 파생 언어들로부터 유래된 데이터 구조입니다. Lisp에서, (“생성 함수 (construct function)”의 줄임말인) cons 함수는 두 개의 인자를 받아 새로운 한 쌍을 생성하는데, 이 인자는 보통 단일 값과 또 다른 쌍입니다. 이러한 쌍들을 담고 있는 쌍들이 리스트를 형성합니다.

cons 함수 개념은 더 일반적인 함수형 프로그래밍 용어로 나아갑니다: “to cons x onto y”는 약식으로 요소 x를 새로운 컨테이너에 집어넣고, 그다음 컨테이너 y를 넣는 식으로 새로운 컨테이너 인스턴스를 생성하는 것을 의미합니다.

cons list 내의 각 아이템은 두 개의 요소를 담고 있습니다: 현재 아이템의 값과 다음 아이템이지요. 리스트의 마지막 아이템은 다음 아이템 없이 Nil 이라 불리는 값을 담고 있습니다. cons list는 cons 함수를 재귀적으로 호출함으로써 만들어집니다. 재귀의 기본 케이스를 의미하는 표준 이름이 바로 Nil 입니다. 유효하지 않은 값 혹은 값이 없는 것을 말하는 6장의 “null” 혹은 “nil” 개념과 동일하지 않다는 점을 주의하세요.

비록 함수형 프로그래밍 언어들이 cons list를 자주 사용할지라도, 러스트에서는 흔히 사용되는 데이터 구조가 아닙니다. 러스트에서 아이템의 리스트를 갖는 대부분의 경우에는, Vec<T>이 사용하기에 더 나은 선택입니다. 그와는 다른, 더 복잡한 재귀적 데이터 타입들은 다양한 상황들에서 유용하기는 하지만, cons list를 가지고 시작함으로써, 박스가 어떻게 재귀적 데이터 타입을 정의하도록 해주는지 우리의 집중을 방해하는 것들 없이 탐구할 수 있습니다.

Listing 15-2는 cons list를 위한 열거형 정의를 담고 있습니다. 우리가 보여주고자 하는 것인데, List 타입이 알려진 크기를 가지고 있지 않고 있기 때문에 이 코드는 아직 컴파일이 안된다는 점을 유의하세요:

Filename: src/main.rs

enum List {
    Cons(i32, List),
    Nil,
}

Listing 15-2: i32 값의 cons list 데이터 구조를 표현하는 열거형 정의에 대한 첫 번째 시도

노트: 이 예제의 목적을 위해 오직 i32 값만 담는 cons list를 구현하고 있습니다. 우리가 10장에서 논의한 것처럼, 임의의 타입 값을 저장할 수 있는 cons list 타입을 정의하기 위해서는 제네릭을 이용해 이를 구현할 수도 있습니다.

List 타입을 이용하여 리스트 1, 2, 3을 저장하는 것은 Listing 15-3의 코드와 같이 보일 것입니다:

Filename: src/main.rs

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

Listing 15-3: List 열거형을 이용하여 리스트 1, 2, 3 저장하기

첫 번째 Cons 값은 1List 값을 갖습니다. 이 List 값은 2와 또 다른 List 값을 갖는 Cons 값입니다. 그 안의 List 값은 3List 값을 갖는 추가적인 Cons인데, 여기서 마지막의 ListNil로서, 리스트의 끝을 알리는 비재귀적인 variant입니다.

만일 Listing 15-3의 코드를 컴파일하고자 시도하면, Listing 15-4에 보이는 에러를 얻습니다:

error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^ recursive type has infinite size
2 |     Cons(i32, List),
  |               ----- recursive without indirection
  |
  = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to
  make `List` representable

Listing 15-4: 재귀적 열거형을 정의하고자 시도했을 때 얻게 되는 에러

이 에러는 이 타입이 “무한한 크기를 갖는다”라고 말해줍니다. 그 원인은 우리가 재귀적인 variant를 이용하여 List를 정의했기 때문입니다: 즉 이것은 또 다른 자신을 직접 값으로 갖습니다. 결과적으로, 러스트는 List 값을 저장하는데 필요한 크기가 얼마나 되는지 알아낼 수 없습니다. 왜 우리가 이런 에러를 얻게 되는지 좀 더 쪼개어 봅시다: 먼저, 러스트가 비재귀적인 타입의 값을 저장하는데 필요한 용량이 얼마나 되는지 결정하는 방법을 살펴봅시다.

비재귀적 타입의 크기 계산하기

6장에서 열거형 정의에 대해 논의할 때 우리가 Listing 6-2에서 정의했던 Message 열거형을 상기해봅시다:


# #![allow(unused_variables)]
#fn main() {
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}
#}

Message 값을 할당하기 위해 얼마나 많은 공간이 필요한지를 결정하기 위해서, 러스트는 어떤 variant가 가장 많은 공간을 필요로 하는지를 알기 위해 각각의 variant들 내부를 봅니다. 러스트는 Message::Quit가 어떠한 공간도 필요 없음을 알게 되고, Message::Move는 두 개의 i32 값을 저장하기에 충분한 공간이 필요함을 알게 되고, 그렇게 진행됩니다. 단 하나의 variant만 사용될 것이기 때문에, Message 값이 필요로 하는 가장 큰 공간은 그것의 varient 중 가장 큰 것을 저장하는데 필요한 공간입니다.

러스트가 Listing 15-2의 List 열거형과 같은 재귀적 타입이 필요로 하는 공간을 결정하고자 시도할 때 어떤 일이 일어나는지를 이와 대조해보세요. 컴파일러는 Cons variant를 살펴보는 것을 시작하는데, 이는 i32 타입의 값과 List 타입의 값을 갖습니다. 그러므로, Consi32의 크기에 List 크기를 더한 만큼의 공간을 필요로 합니다. List 타입이 얼마나 많은 메모리를 차지하는지 알아내기 위해서, 컴파일러는 그것의 variants를 살펴보는데, 이는 Cons variant로 시작됩니다. Cons variant는 i32 타입의 값과 List 타입의 값을 갖고, 이 과정은 Figure 15-1에서 보는 바와 같이 무한히 계속됩니다:

An infinite Cons list

Figure 15-1: 무한한 Cons variant를 가지고 있는 무한한 List

Box<T>를 이용하여 알려진 크기를 가진 재귀적 타입 만들기

러스트는 재귀적으로 정의된 타입을 위하여 얼마큼의 공간을 할당하는지 알아낼 수 없으므로, 컴파일러는 Listing 15-4의 에러를 내줍니다. 하지만 이 에러는 아래와 같은 유용한 제안을 포함하고 있습니다:

  = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to
  make `List` representable

이 제안에서, “간접 (indirection)”은 값을 직접 저장하는 대신, 간접적으로 값의 포인터를 저장하기 위하여 데이터 구조를 바꿀 수 있음을 의미합니다.

Box<T>가 포인터이기 때문에, 러스트는 언제나 Box<T>가 필요로 하는 공간이 얼마인지 알고 있습니다: 포인터의 크기는 그것이 가리키고 있는 데이터의 양에 기반하여 변경되지 않습니다. 이는 우리가 Cons variant 내에 또 다른 List 값을 직접 넣는 대신 Box<T>를 넣을 수 있다는 뜻입니다. Box<T>Cons variant 안에 있기보다는 힙에 있을 다음의 List 값을 가리킬 것입니다. 개념적으로, 우리는 다른 리스트들을 “담은” 리스트들로 만들어진 리스트를 여전히 갖게 되지만, 이 구현은 이제 또 다른 것 안의 아이템들이 아니라 또 다른 것 옆에 있는 아이템들에 더 가깝습니다.

우리는 Listing 15-2의 List 열거형의 정의와 Listing 15-3의 List 사용법을 Listing 15-5의 코드로 바꿀 수 있는데, 이는 컴파일될 것입니다:

Filename: src/main.rs

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1,
        Box::new(Cons(2,
            Box::new(Cons(3,
                Box::new(Nil))))));
}

Listing 15-5: 알려진 크기를 갖도록 하기 위해 Box<T>를 이용하는 List의 정의

Cons variant는 i32와 박스의 포인터 데이터를 저장할 공간을 더한 크기를 요구할 것입니다. Nil variant는 아무런 값도 저장하지 않으므로, Cons variant에 비해 공간을 덜 필요로 합니다. 우리는 이제 어떠한 List 값이 i32의 크기 더하기 박스의 포인터 데이터의 크기만큼을 차지할 것인 점을 알게 되었습니다. 박스를 이용함으로써, 우리는 무한하고, 재귀적인 연결을 부수었고, 따라서 컴파일러는 List 값을 저장하는데 필요한 크기를 알아낼 수 있습니다. Figure 15-2는 Cons variant가 이제 어떻게 생겼는지를 보여주고 있습니다:

A finite Cons list

Figure 15-2: ConsBox를 들고 있기 때문에 무한한 크기가 아니게 된 List

박스는 단지 간접 및 힙 할당만을 제공할 뿐입니다; 이들은 다른 어떤 특별한 능력들, 우리가 다른 스마트 포인터 타입들에서 보게 될 것 같은 능력들이 없습니다. 또한 이들은 이러한 특별한 능력들이 초래하는 성능적인 오버헤드도 가지고 있지 않으므로, 우리가 필요로 하는 기능이 딱 간접 하나인 cons list와 같은 경우에 유용할 수 있습니다. 우리는 또한 17장에서 박스에 대하여 더 많은 사용례를 살펴볼 것입니다.

Box<T> 타입은 스마트 포인터인데 그 이유는 이것이 Deref 트레잇을 구현하고 있기 때문이며, 이는 Box<T> 값이 참조자와 같이 취급되도록 허용해줍니다. Box<T> 값이 스코프 밖으로 벗어날 때, 박스가 가리키고 있는 힙 데이터도 마찬가지로 정리되는데 이는 Drop 트레잇의 구현 때문에 그렇습니다. 이 두 가지 트레잇에 대하여 더 자세히 탐구해 봅시다. 이 두 트레잇이 이 장의 나머지에서 다루게 될 다른 스마트 포인터 타입에 의해 제공되는 기능들보다 심지어 더 중요할 것입니다.

Deref 트레잇을 가지고 스마트 포인터를 평범한 참조자와 같이 취급하기

Deref 트레잇을 구현하는 것은 우리가 (곱하기 혹은 글롭 연산자와는 반대 측에 있는) 역참조 연산자 (dereference operator)