시작하기
Rust에 기여하는 데 관심을 가져 주셔서 감사합니다! 기여하는 방법은 다양하며, 저희는 그 모든 기여에 감사드립니다.
Rust에 기여하는 방법에 관한 일반적인 정보는 Forge를 참고하십시오. 이 절의 나머지 부분은 특히 컴파일러에 기여하는 방법을 다룹니다.
이 문서는 모든 내용을 포괄하기 위한 것이 아니며, 가장 유용한 내용을 빠르게 안내하기 위한 것입니다. 자세한 내용은 컴파일러를 빌드하고 실행하는 방법을 참고하십시오.
도움 받기
“질문하기”도 참고하십시오.
전문가
모든 t-compiler 구성원이 rustc의 모든 부분에 정통한 것은 아닙니다. 상당히 큰 프로젝트이기 때문입니다. 컴파일러의 각 부분에 대해 누가 전문 지식을 갖추고 있는지 알아보려면 triagebot 할당 그룹을 참고하십시오. triagebot.toml 파일에서 [assign*로 시작하는 섹션들이 이에 해당합니다. 하지만 누구에게 핑을 보내야 할지 모르더라도 부담 없이 질문하십시오.
컴파일러의 특정 부분에 대한 전문가를 찾는 또 다른 방법은 최근에 누가 커밋했는지 살펴보는 것입니다. 예를 들어, 1.68.2 릴리스 이후 최근에 이름 해석 작업을 한 사람을 찾으려면 git shortlog -n 1.68.2.. compiler/rustc_resolve/를 실행하면 됩니다. “Rollup merge“로 시작하는 커밋이나 @bors가 만든 커밋은 무시하십시오(이러한 커밋에 대한 자세한 내용은 CI 기여 절차를 참고하십시오).
무엇을 작업해야 합니까?
rust 모노레포는 규모가 상당히 커서, 어느 부분에 도움이 필요한지, 또는 어느 부분이 초보자가 시작하기에 좋은지 알기 어려울 수 있습니다. 다음은 시작하기 좋은 곳으로 추천하는 몇 가지입니다.
쉬운 이슈 또는 멘토링이 제공되는 이슈
어디서부터 시작할지 찾고 계시다면 다음 이슈 검색을 확인해 보십시오. 이 레이블들에 대한 설명은 Triage를 참고하십시오. 관심 있는 분야로 검색을 필터링해 볼 수도 있습니다. 예를 들면 다음과 같습니다.
label:T-compiler는 컴파일러와 관련된 이슈만 보여 줍니다label:A-diagnostics는 진단 관련 이슈만 보여 줍니다
중요한 작업이나 초보자용 작업에 모두 이슈 레이블이 붙어 있는 것은 아닙니다. 레이블이 붙지 않은 작업을 찾는 방법은 아래를 참고하십시오.
반복적인 작업
어떤 작업은 한 사람이 하기에는 너무 큽니다. 이런 경우에는 기여자들 사이의 작업을 조율하기 위해 “추적 이슈(Tracking issues)“를 두는 것이 일반적입니다. 다음은 많은 시간을 들이지 않고도 쉽게 작업을 맡을 수 있는 추적 이슈의 예입니다:
- 반복 작업 항목을 여기에 추가하십시오.
반복 작업을 더 발견하시면 언제든지 여기에 추가해 주십시오!
진단 이슈
많은 진단 이슈는 그 자체로 완결되어 있어 컴파일러에 대한 자세한 배경지식이 필요하지 않습니다. 진단 이슈 목록은 여기에서 볼 수 있습니다.
방치된 풀 리퀘스트 이어받기
때때로 기여자가 풀 리퀘스트를 보낸 뒤, 작업할 시간이 충분하지 않다는 것을 나중에 알게 되거나 단순히 더 이상 관심이 없어지는 경우가 있습니다. 이러한 PR은 결국 닫히는 경우가 많으며 S-inactive 레이블이 붙습니다. 이러한 PR 중 일부를 살펴보고 작업을 이어받아 볼 수 있습니다. 이러한 PR 목록은 여기에서 찾을 수 있습니다.
그사이에 해당 PR이 다른 방식으로 구현되었다면 그 PR에서 S-inactive 레이블을 제거해야 합니다. 그렇지 않고 해당 변경에 여전히 관심이 있는 것으로 보인다면, 풀 리퀘스트를 최신 main 브랜치 위로 리베이스한 뒤 새 풀 리퀘스트를 보내 해당 기능에 대한 작업을 이어갈 수 있습니다.
테스트 작성하기
해결되었지만 회귀 테스트가 없는 이슈에는 E-needs-test 레이블이 붙어 있습니다. 단위 테스트 작성은 위험이 적고 우선순위가 비교적 낮은 작업으로, 새로운 기여자가 테스트 인프라와 기여 워크플로에 익숙해질 수 있는 좋은 기회가 됩니다. 테스트가 필요한 이슈 목록은 여기에서 볼 수 있습니다.
std(표준 라이브러리)에 기여하기
std-dev-guide를 참고하십시오.
그 밖의 기여 방법
Forge를 참고하십시오.
클론 및 빌드
“컴파일러를 빌드하고 실행하는 방법”을 참고하십시오.
버그 수정 또는 “일반적인” 코드 변경
대부분의 PR에는 특별한 절차가 필요하지 않습니다. PR을 열기만 하면 리뷰와 승인을 거쳐 병합됩니다. 여기에는 대부분의 버그 수정, 리팩터링, 그 밖에 사용자에게 보이지 않는 변경이 포함됩니다. 다음 몇 개의 절에서는 이 규칙의 예외를 설명합니다.
또한 WIP PR이나 GitHub Draft PR을 여는 것도 전혀 문제가 없다는 점에 유의하십시오. 어떤 사람들은 작업 도중에 피드백을 받거나 협업자와 코드를 공유하기 위해 이 방식을 선호합니다. 또 어떤 사람들은 CI를 활용하여 자신의 PR을 빌드하고 테스트하기 위해 이 방식을 사용합니다(예: 느린 머신에서 개발하는 경우).
풀 리퀘스트
풀 리퀘스트(줄여서 PR)는 Rust를 변경하는 데 사용하는 주된 수단입니다. GitHub 자체에도 풀 리퀘스트 기능 사용법에 관한 훌륭한 문서가 있습니다. 저희는 “fork and pull” 모델을 사용합니다. 이 모델에서 기여자는 개인 포크에 변경 사항을 푸시하고, 그 변경 사항을 원본 저장소에 반영하기 위해 풀 리퀘스트를 생성합니다. Rust에 기여할 때 Git을 사용하는 방법에 관한 장도 마련되어 있습니다.
규모가 크거나, 복잡하거나, 여러 영역에 걸치거나, 매우 특정 도메인에 국한될 수 있는 변경에 대한 조언
순번제로 리뷰를 맡는 컴파일러 리뷰어들은 대개 각자 잘 아는 컴파일러 영역이 있지만, 그다지 익숙하지 않은 영역도 있습니다. PR에 규모가 크거나, 복잡하거나, 여러 영역에 걸치거나, 매우 특정 도메인에 국한된 변경이 포함되어 있다면, 그러한 PR의 모든 변경 사항을 무리 없이 리뷰할 수 있는 적절한 리뷰어를 찾기가 매우 어려워집니다. 변경 사항이 컴파일러에만 국한되지 않고 표준 라이브러리 팀처럼 다른 팀 리뷰어의 소관에 속하는 변경까지 포함하는 경우에도 마찬가지입니다. 수정된 파일을 기준으로 관련 팀에 알리고 특정 알림을 설정해 둔 사람들을 호출하는 봇이 있습니다.
이러한 변경을 하기 전에 제안하는 변경 사항을 컴파일러 팀과 미리 논의하고(변경에 승인이 필요한 다른 팀과도 논의하십시오), 컴파일러 팀과 협력하여 리뷰하기 어려울 수 있는 큰 PR을 개별적으로 리뷰하기 더 쉬운 작은 PR 여러 개로 나누는 데 도움을 받을 수 있는지 확인하기를 강력히 권장합니다.
Zulip에 #t-compiler 스레드를 만들어 제안하는 변경 사항을 논의함으로써 컴파일러 팀과 소통할 수 있습니다.
컴파일러 팀과 미리 소통하면 여러 면에서 도움이 됩니다.
- PR이 적시에 리뷰될 가능성이 높아집니다.
- 실제 PR을 열기 전에 적절한 리뷰어를 찾도록 도와드리거나, 변경 절차를 진행하는 데 도움을 줄 조언자와 연락 담당자를 찾아 드리거나, 필요에 따라 try-job, perf 실행, crater 실행을 도와드릴 수 있습니다.
- 컴파일러 팀이 변경 사항을 추적하는 데 도움이 됩니다.
- 컴파일러 팀은 변경 방향이 컴파일러 팀이 선호하는 방향과 일치하는지 확인하기 위해 변경 사항을 일찍부터 자주 가볍게 점검(vibe check)할 수 있습니다.
- 컴파일러 팀이 받아들이지 않을 수도 있는 큰 변경에 상당한 시간과 노력을 들이거나, 변경 방향이 컴파일러 팀의 의견과 다르다는 사실을 너무 늦게 알게 되는 상황을 피하는 데 도움이 됩니다.
이슈 분류
https://forge.rust-lang.org/release/issue-triaging.html을 참조하십시오.
기타 자료
이 절은 “이 가이드에 대하여” 장으로 옮겨졌습니다.
이 가이드에 대하여
이 가이드는 Rust 컴파일러인 rustc가 어떻게 동작하는지 문서화하고, 새로운 기여자가 rustc 개발에 참여할 수 있도록 돕기 위해 작성되었습니다.
이 가이드는 참고 자료로 쓰도록 만들어졌다는 점에 유의하십시오. 처음부터 끝까지 읽기보다는 필요한 문서를 검색해서 찾아보시기를 권장합니다.
이 가이드는 다음과 같은 여러 부분으로 구성되어 있습니다.
rustc빌드 및 디버깅: 어떤 방식으로 기여하든 유용한 빌드, 디버깅, 프로파일링 등에 관한 정보를 담고 있습니다.- Rust에 기여하기: 어떤 방식으로 기여하든 유용한 기여 절차, git과 GitHub 사용법, 기능 안정화 등에 관한 정보를 담고 있습니다.
- 부트스트래핑: Rust 컴파일러가 이전 버전을 사용하여 자기 자신을 빌드하는 방법을 설명하며, 부트스트랩 과정 소개와 디버깅 방법을 포함합니다.
- 컴파일러의 고수준 아키텍처: 컴파일러의 고수준 아키텍처와 컴파일 과정의 각 단계를 다룹니다.
- 소스 코드 표현: 사용자로부터 받은 원시 소스 코드를 컴파일러가 쉽게 다룰 수 있는 다양한 형태로 변환하는 과정을 설명합니다.
- 지원 인프라: 명령줄 인자 규칙, rustc_driver와 rustc_interface 같은 컴파일러 진입점, 그리고 오류와 린트의 설계 및 구현을 다룹니다.
- 분석: 컴파일러가 코드의 다양한 속성을 검사하고 컴파일 과정의 이후 단계(예: 타입 검사)에 정보를 제공하기 위해 사용하는 분석을 다룹니다.
- MIR에서 바이너리까지: 링크된 실행 가능한 기계어 코드가 생성되는 방식을 다룹니다.
- 마지막에는 유용한 참고 정보를 담은 부록이 있습니다. 부록은 여러 개가 있으며 각각 다른 정보를 담고 있고, 용어집도 그중 하나입니다.
끊임없는 변화
rustc는 상당수의 기여자가 지속적으로 작업하고 있는 실제 프로덕션 수준의 제품이라는 점을 명심하십시오. 그렇기 때문에 코드베이스의 잦은 변경과 기술 부채도 상당합니다. 또한 이 가이드 전반에서 다루는 많은 아이디어는 아직 완전히 구현되지 않은 이상적인 설계입니다. 이 모든 이유로 이 가이드의 모든 내용을 완전히 최신 상태로 유지하기란 매우 어렵습니다!
물론 이 가이드 자체도 오픈 소스이며, 소스는 GitHub 저장소에서 호스팅됩니다. 가이드에서 오류를 발견하시면 이슈를 등록해 주십시오. 수정 사항을 담은 PR을 열어 주시면 더욱 좋습니다!
가이드에 기여하신다면 이 가이드의 문서 작성에 관한 하위 절을 참고하십시오.
“‘조건 지어진 모든 것은 무상하다’ — 이것을 지혜로써 볼 때, 사람은 괴로움에서 벗어납니다.” 법구경, 277게
정보를 찾을 수 있는 다른 곳
지금 읽고 계신 이 가이드는 컴파일러의 여러 부분이 어떻게 동작하는지, 그리고 컴파일러에 어떻게 기여하는지에 관한 정보를 담고 있습니다.
다음 사이트도 유용할 수 있습니다.
- rustc API 문서 – 컴파일러, 개발 도구, 내부 도구에 대한 rustdoc 문서입니다.
- Forge – Rust 인프라, 팀 절차 등에 관한 문서를 담고 있습니다.
rust-lang/rust의 LLM 정책- compiler-team – Rust 컴파일러 팀의 본거지로, 팀 절차, 활동 중인 작업 그룹, 팀 일정에 대한 설명이 있습니다.
- std-dev-guide – 표준 라이브러리 개발을 위한 비슷한 가이드입니다.
- rust-analyzer 책 – rust-analyzer에 관한 문서입니다.
- t-compiler(컴파일러 팀) Zulip
- Rust 내부 구조에 관해 질문하고 토론할 수 있는 곳인 Rust Internals 포럼
- Rust 레퍼런스는 Rust 내부 구조를 따로 다루지는 않지만, 그럼에도 훌륭한 자료입니다
- 오래되기는 했지만 Tom Lee의 훌륭한 블로그 글은 큰 도움이 됩니다
- Rust 컴파일러 테스트 문서
- @bors에 관해서는 이 치트 시트가 유용합니다
- 프로그래밍할 때는 언제나 Google이 도움이 됩니다. 모든 Rust 문서를 검색하여(표준 라이브러리, 컴파일러, 책, 레퍼런스, 가이드) 언어와 컴파일러에 관한 정보를 빠르게 찾을 수 있습니다.
- Rustdoc에 내장된 검색 기능을 사용하여 살펴보고 있는 크레이트 안의 타입과 함수에 관한 문서를 찾을 수도 있습니다. 타입 시그니처로 검색할 수도 있습니다! 예를 들어
* -> vec를 검색하면Vec<T>를 반환하는 모든 함수를 찾을 수 있습니다. 힌트: 아무 Rustdoc 페이지에서나?를 입력하면 더 많은 팁과 키보드 단축키를 볼 수 있습니다!
컴파일러를 빌드하고 실행하는 방법
profile = "library" 사용자 또는 download-rustc = true | "if-unchanged"를 사용하는 사용자는 download-rustc가 활성화된(즉, 컴파일러 변경 사항이 없는) 상태에서의 ./x test library/std 흐름이 현재 동작하지 않는다는 점에 유의하십시오. 이 문제는 https://github.com/rust-lang/rust/issues/142505에서 추적하고 있습니다. 이 경우 영향을 받는 것은 ./x test 흐름뿐이며, ./x {check,build} library/std는 여전히 동작해야 합니다.
당분간은 ./x test library/std에 대해 download-rustc를 비활성화해야 할 수도 있습니다. 다음 방법 중 하나로 비활성화할 수 있습니다:
./x test library/std --set rust.download-rustc=false- 또는
bootstrap.toml에서rust.download-rustc = false를 설정하십시오.
안타깝게도 이 방법을 사용하면 stage 1 컴파일러를 빌드해야 합니다. bootstrap 팀이 이 문제를 해결하기 위해 작업하고 있지만, 유지 보수가 가능한 수정 사항을 구현하는 데 시간이 걸리고 있습니다.
컴파일러는 bootstrap이라는 도구를 사용하여 빌드합니다. 이 도구를 실행하려면 Python이 설치되어 있어야 합니다.
빠른 시작
컴파일러를 실행하는 과정을 더 간단히 다루는 빠른 시작 안내는 빠른 시작을 참조하십시오.
소스 코드 받기
메인 저장소는 rust-lang/rust입니다. 이 저장소에는 컴파일러, 표준 라이브러리(core, alloc, test, proc_macro 등 포함), 그리고 여러 도구(예: rustdoc, 부트스트래핑 인프라 등)가 들어 있습니다.
rustc 작업을 시작하는 가장 첫 단계는 저장소를 클론하는 것입니다:
git clone https://github.com/rust-lang/rust.git
cd rust
참고: Windows에서는 대규모 체크아웃의 경우 백신 검사로 인해 Git 및 빌드 명령이 눈에 띄게 느려질 수 있습니다. 그런 경우에는 Rust 체크아웃 디렉터리만 Windows 보안 제외 목록에 추가하는 것을 고려하십시오. 필요 이상으로 넓은 범위를 제외 항목으로 추가하지 마십시오.
저장소 부분 클론하기
저장소의 크기 때문에 느린 인터넷 연결에서는 클론하는 데 오랜 시간이 걸릴 수 있으며, 모든 파일과 디렉터리의 전체 이력을 저장할 디스크 공간도 필요합니다. 대신 git에 부분 클론을 수행하도록 지시할 수 있습니다. 이 경우 현재 파일 내용만 완전히 가져오고, 예를 들어 이력의 과거 시점으로 이동할 때 추가 파일 내용을 자동으로 가져옵니다. 모든 git 명령은 평소와 같이 계속 동작하지만, 아직 불러오지 않은 이력 시점을 방문하려면 인터넷 연결이 필요하다는 대가가 따릅니다.
git clone --filter='blob:none' https://github.com/rust-lang/rust.git
cd rust
참고: 이 링크에서는 이러한 유형의 체크아웃을 더 자세히 설명하고, 얕은 클론과 같은 다른 방식과도 비교합니다.
저장소 얕은 클론하기
부분 클론보다 오래된 대안으로 저장소를 얕은 클론하는 방법이 있습니다. 이렇게 하려면 git clone 명령에 --depth N 옵션을 사용하면 됩니다. 이 옵션은 git에 “얕은 클론“을 수행하도록 지시하여, 저장소를 클론하되 마지막 N개의 커밋까지만 남기고 잘라냅니다.
--depth 1을 전달하면 git은 저장소를 클론하되 이력을 main 브랜치의 최신 커밋까지만 남기고 잘라내며, 소스 코드를 살펴보거나 컴파일러를 빌드하는 데는 대개 이것으로 충분합니다.
git clone --depth 1 https://github.com/rust-lang/rust.git
cd rust
참고: 얕은 클론을 사용하면 실행할 수 있는
git명령이 제한됩니다. 컴파일러 작업에 참여하고 기여할 계획이라면 일반적으로 위에 나온 대로 저장소 전체를 클론하거나 대신 부분 클론을 수행할 것을 권장합니다.예를 들어
git bisect와git blame은 커밋 기록에 접근해야 하므로 저장소를--depth 1로 클론한 경우에는 작동하지 않습니다.
bootstrap이란 무엇입니까?
bootstrap은 rust 저장소의 빌드 도구입니다. bootstrap으로 문서를 빌드하고, 테스트를 실행하고, 컴파일러와 표준 라이브러리를 빌드할 수 있습니다.
이 장에서는 생산적으로 작업하는 데 필요한 기본 사항을 중점적으로 다룹니다. bootstrap에 대해 더 알고 싶다면 이 장을 읽으십시오.
bootstrap 실행하기
bootstrap은 대부분의 시스템(Unix, 설정된 경우 Windows)에서 다음과 같이 실행할 수 있습니다.
./x <subcommand> [flags]
이 문서와 예제는 이 방법으로 bootstrap을 실행한다고 가정합니다. 다른 실행 방법은 다음과 같습니다.
# In Windows PowerShell (if PowerShell is configured to run scripts)
./x <subcommand> [flags]
# In NuShell (if PowerShell is configured to run scripts). `./x` does not work in NuShell if `.py` files are not configured to run Python.
./x.ps1 <subcommand> [flags]
# On the Windows Command Prompt (if .py files are configured to run Python)
x.py <subcommand> [flags]
# You can also run Python yourself, e.g.:
python x.py <subcommand> [flags]
# On a Unix shell if you have `python3` but an `sh` that doesn't support `local`, e.g. on Solaris
./x.py <subcommand> [flags]
Windows에서는 PowerShell 명령을 실행할 때 다음과 같은 오류가 발생할 수 있습니다.
PS C:\Users\vboxuser\rust> ./x
./x : File C:\Users\vboxuser\rust\x.ps1 cannot be loaded because running scripts is disabled on this system. For more
information, see about_Execution_Policies at https://go.microsoft.com/fwlink/?LinkID=135170.
At line:1 char:1
+ ./x
+ ~~~
+ CategoryInfo : SecurityError: (:) [], PSSecurityException
+ FullyQualifiedErrorId : UnauthorizedAccess
PowerShell이 로컬 스크립트를 실행하도록 허용하면 이 오류를 피할 수 있습니다.
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
bootstrap을 조금 더 편리하게 실행하기
bootstrap을 감싸는 x라는 바이너리가 있습니다. 이 바이너리는 ./x를 실행하며, 시스템 전역에 설치해 체크아웃의 어느 하위 디렉터리에서든 실행할 수 있습니다. 또한 사용할 적절한 Python 버전을 찾아 주며, 현재 사용 중인 셸에 의존하지 않습니다.
cargo install --locked --path src/tools/x로 설치할 수 있습니다.
bootstrap.toml 만들기
먼저 ./x setup을 실행하고 compiler 기본값을 선택하십시오. 그러면 몇 가지 초기화 작업을 수행하고 적절한 기본값으로 bootstrap.toml을 만들어 줍니다. 다른 기본값을 사용한다면(rustdoc처럼 컴파일러가 아닌 rust의 다른 영역에 기여하려는 경우라면 그렇게 하게 될 가능성이 높습니다) 기본값에 따라 빌드 과정이 다를 수 있으므로 해당 기본값에 대한 정보(src/bootstrap/defaults에 있음)를 반드시 읽으십시오.
또는 bootstrap.toml을 직접 작성할 수도 있습니다. 사용 가능한 모든 설정과 각 설정의 역할은 bootstrap.example.toml을 참고하십시오. 자주 변경하는 설정은 src/bootstrap/defaults를 참고하십시오.
이미 rustc를 빌드한 뒤 LLVM 관련 설정을 변경했다면 이후의 설정 변경 사항을 적용하기 위해 ./x clean --all을 실행해야 할 수도 있습니다. ./x clean을 실행해도 LLVM은 다시 빌드되지 않는다는 점에 유의하십시오.
자주 쓰는 x 명령
다음은 rustc, std, rustdoc 및 기타 도구를 작업할 때 가장 자주 사용하는 x 명령의 기본 사용법입니다.
| 명령 | 사용 시점 |
|---|---|
./x check | 대부분의 코드가 컴파일되는지 빠르게 확인합니다. rust-analyzer가 이 작업을 자동으로 실행해 줄 수 있습니다 |
./x build | rustc, std, rustdoc을 빌드합니다 |
./x test | 모든 테스트를 실행합니다 |
./x fmt | 모든 코드를 포매팅합니다 |
이 명령들은 위에 적힌 그대로 사용해도 무난한 출발점입니다. 하지만 각 명령에는 본격적인 개발 작업을 위해 익혀 둘 만한 추가 옵션과 인자가 있습니다. 특히 ./x build와 ./x test는 코드의 일부만 컴파일하거나 테스트하는 다양한 방법을 제공하므로 시간을 크게 절약할 수 있습니다.
또한 x는 compiler, library, src/tools 디렉터리에 대해 모든 종류의 경로 접미사를 지원한다는 점에 유의하십시오. 따라서 x test src/tools/tidy 대신 간단히 x test tidy를 실행할 수 있습니다. 또는 x build library/std 대신 x build std를 실행할 수 있습니다.
자세한 내용은 테스트 및 rustdoc에 관한 장을 참고하십시오.
컴파일러 빌드하기
빌드에는 비교적 많은 저장 공간이 필요하다는 점에 유의하십시오. 컴파일러를 빌드하려면 10기가바이트에서 15기가바이트 이상의 여유 공간을 확보해 두는 것이 좋습니다.
bootstrap.toml을 만들었다면 이제 x를 실행할 준비가 된 것입니다. 여기에는 많은 옵션이 있지만, 로컬 컴파일러를 빌드할 때 가장 기본으로 쓰기 좋은 명령부터 시작해 봅시다:
./x build library
이 명령이 하는 일은 다음과 같습니다:
- stage0 컴파일러와 stage0
std를 사용하여rustc를 빌드합니다. - 방금 빌드한 stage1 컴파일러로
library(표준 라이브러리)를 빌드합니다. - stage1 컴파일러와 stage1 표준 라이브러리를 포함하는, 동작하는 stage1 sysroot를 구성합니다.
다른 Rust 프로그램을 빌드하는 데 필요한 것이 바로 이 최종 결과물(stage1 컴파일러 + 그 컴파일러로 빌드한 라이브러리)입니다(#![no_std]나 #![no_core]를 사용하는 경우는 제외합니다).
stage1 std를 빌드하는 과정이 병목이 된다고 느끼실 수도 있지만 걱정하지 마십시오. (편법이긴 하지만) 우회 방법이 있습니다… std 재빌드 피하기에 관한 절을 참고하십시오.
때로는 전체 빌드가 필요하지 않습니다. 메서드 이름을 바꾸거나 어떤 함수의 시그니처를 변경하는 것과 같은 일종의 “타입 기반 리팩터링“을 할 때에는 대신 ./x check를 사용하여 훨씬 빠르게 빌드할 수 있습니다.
이 명령 전체는 전체 rustc 빌드의 일부만 수행한다는 점에 유의하십시오. 전체 rustc 빌드(./x build --stage 2 rustc를 실행하면 얻는 것)에는 훨씬 더 많은 단계가 있습니다:
- stage1 컴파일러로
rustc를 빌드합니다.- 여기서 만들어지는 컴파일러를 “stage2” 컴파일러라고 하며, 이 컴파일러는 이전 명령에서 만든 stage1 std를 사용합니다.
- stage2 컴파일러로
librustdoc과 그 밖의 여러 가지를 빌드합니다.
이 작업을 해야 하는 경우는 거의 없습니다.
특정 컴포넌트 빌드하기
표준 라이브러리를 작업하고 있다면 다른 기본 컴포넌트를 모두 빌드할 필요는 없을 것입니다. 대신 다음과 같이 이름을 지정하여 특정 컴포넌트를 빌드할 수 있습니다:
./x build --stage 1 library
x setup을 실행할 때 library 프로필을 선택했다면 --stage 1을 생략할 수 있습니다(이것이 기본값입니다).
도구를 빌드하려면 다음을 사용할 수 있습니다:
./x build src/tools/cargo
도구 테스트에 관한 절도 확인해 보십시오.
rustup 툴체인 만들기
rustc를 성공적으로 빌드하고 나면 build 디렉터리에 여러 파일이 생성되어 있을 것입니다. 이렇게 만들어진 rustc를 실제로 실행하려면 rustup 툴체인을 만드는 것을 권장합니다. 아래에 나열된 첫 번째 명령은 위의 단계에서 빌드한 stage1 툴체인을 stage1이라는 이름으로 만듭니다. 두 번째 명령은 stage1 컴파일러를 사용하여 stage2 툴체인을 만듭니다. 이는 나중에 전체 테스트 스위트를 실행할 때 필요하지만, 이 페이지에서는 빌드하지 않습니다. stage2 빌드는 stage1과 같은 ./x build 명령으로 하되, 스테이지를 2로 지정하면 됩니다.
rustup toolchain link stage1 build/host/stage1
rustup toolchain link stage2 build/host/stage2
이제 빌드한 rustc를 툴체인을 통해 실행할 수 있습니다. -vV로 실행하면 -dev로 끝나는 버전 번호가 표시되며, 이는 로컬 환경에서 빌드했음을 나타냅니다:
$ rustc +stage1 -vV
rustc 1.48.0-dev
binary: rustc
commit-hash: unknown
commit-date: unknown
host: x86_64-unknown-linux-gnu
release: 1.48.0-dev
LLVM version: 11.0
rustup 툴체인은 build 디렉터리에서 컴파일된 지정 툴체인을 가리키므로, 해당 툴체인/스테이지에 대해 x build나 x test를 실행할 때마다 rustup 툴체인도 갱신됩니다.
참고: 여기서 빌드한 툴체인에는 cargo가 포함되어 있지 않습니다. 이 경우 rustup은 설치된 nightly, beta, stable 툴체인(이 순서대로)의 cargo를 대신 사용합니다. 불안정한 cargo 플래그를 사용해야 한다면, 아직 실행하지 않았을 경우 반드시 rustup install nightly를 실행하십시오. 사용자 정의 툴체인에 관한 rustup 문서를 참고하십시오.
참고: rust-analyzer와 IntelliJ Rust 플러그인은 절차적 매크로를 다루기 위해 rust-analyzer-proc-macro-srv라는 컴포넌트를 사용합니다. 프로젝트에 사용자 정의 툴체인을 사용하려 한다면(예: rustup override set stage1을 통해) 이 컴포넌트를 빌드하는 것이 좋습니다:
./x build proc-macro-srv-cli
교차 컴파일용 타깃 빌드하기
다른 타깃으로 교차 컴파일할 수 있는 컴파일러를 만들려면 x build에 target 플래그를 원하는 만큼 전달하십시오. 예를 들어 호스트 플랫폼이 x86_64-unknown-linux-gnu이고 교차 컴파일 타깃이 wasm32-wasip1이라면 다음과 같이 빌드할 수 있습니다:
./x build --target x86_64-unknown-linux-gnu,wasm32-wasip1
만들어진 컴파일러로 절차적 매크로나 빌드 스크립트가 포함된 크레이트를 빌드하려면, 호스트 플랫폼(이 경우 x86_64-unknown-linux-gnu)에 대한 타깃 지원을 반드시 명시적으로 빌드해야 한다는 점에 유의하십시오.
x build에 플래그를 전달하지 않고도 항상 다른 타깃용으로 빌드하려면 bootstrap.toml의 [build] 섹션에서 다음과 같이 설정할 수 있습니다:
build.target = ["x86_64-unknown-linux-gnu", "wasm32-wasip1"]
일부 타깃용으로 빌드하려면 외부 의존성이 설치되어 있어야 한다는 점에 유의하십시오(예: musl 타깃을 빌드하려면 musl의 로컬 사본이 필요합니다). 타깃별 설정(예: musl 로컬 사본의 경로)은 모두 bootstrap.toml에서 제공해야 합니다. 타깃별 설정 키에 관한 정보는 bootstrap.example.toml을 참고하십시오.
타깃을 빌드하는 데 필요한 전체 구성의 예시를 보려면 rustc 책을 방문하여 왼쪽의 “Platform Support” 제목 아래에서 원하는 타깃을 선택한 다음, 해당 타깃용 컴파일러 빌드에 관한 섹션을 참고하십시오. rustc 책에 해당 페이지가 없는 타깃이라면, Rust 인프라가 교차 컴파일을 설정하고 구성하는 데 직접 사용하는 Dockerfile을 살펴보는 것이 도움이 될 수 있습니다.
이전 섹션의 안내에 따라 rustup 툴체인을 만들었다면, 컴파일러를 빌드한 뒤 다음과 같이 교차 컴파일에 사용할 수 있습니다:
cargo +stage1 build --target wasm32-wasip1
기타 x 명령
다음은 그 밖에 유용한 x 명령입니다. 이 중 일부는 다른 섹션에서 자세히 다룹니다:
- 빌드:
./x build–std까지만이 아니라 전체를 stage 1 컴파일러로 빌드합니다./x build --stage 2–rustdoc를 포함한 전체를 stage 2 컴파일러로 빌드합니다
- 테스트 실행(자세한 내용은 테스트 실행 섹션을 참고하십시오):
./x test library/std–std의 단위 테스트와 통합 테스트를 실행합니다./x test tests/ui–ui테스트 스위트를 실행합니다./x test tests/ui/const-generics-ui테스트 스위트의const-generics/하위 디렉터리에 있는 모든 테스트를 실행합니다./x test tests/ui/const-generics/const-types.rs-ui테스트 스위트에서const-types.rs테스트 하나만 실행합니다
빌드 디렉터리 정리
가끔은 처음부터 다시 시작해야 할 때도 있지만, 보통은 그럴 필요가 없습니다. 이 작업이 필요하다면 bootstrap이 제대로 동작하지 않고 있을 가능성이 높으므로, 무엇이 잘못되었는지 버그로 보고해야 합니다. 정말로 모두 정리해야 한다면 명령 하나만 실행하면 됩니다!
./x clean
rm -rf build로도 되지만, 이 경우 LLVM을 다시 빌드해야 하며 빠른 컴퓨터에서도 시간이 오래 걸릴 수 있습니다.
디스크 공간에 관한 참고 사항
컴파일러를 빌드하려면(특히 stage 1을 넘어서는 경우) 상당한 여유 디스크 공간이 필요하며, 100GB 정도가 필요할 수도 있습니다. rust-analyzer용 빌드 디렉터리(예: build-rust-analyzer)를 따로 두고 있다면 필요한 공간은 더 늘어납니다. 사용자마다 디스크 할당량이 정해져 있는 dev-desktop에서는 이 한도에 쉽게 도달하지만, 로컬 개발 환경에도 똑같이 해당됩니다. 때때로 다음 작업이 필요할 수 있습니다:
build/디렉터리를 삭제하십시오.build-rust-analyzer/디렉터리를 삭제하십시오(rust-analyzer용 빌드 디렉터리를 따로 두고 있는 경우).cargo-bisect-rustc를 사용한다면 필요 없는 툴체인을 제거하십시오. 설치된 툴체인은rustup toolchain list로 확인할 수 있습니다.
빠른 시작
이 문서는 컴파일러를 실행하는 방법을 다루는 빠른 시작 안내서입니다. 각 단계에 대한 자세한 내용은 이 장의 다른 페이지를 참고하십시오.
먼저 저장소를 클론하십시오:
git clone https://github.com/rust-lang/rust.git
cd rust
컴파일러를 빌드할 때는 cargo를 직접 사용하지 않습니다. 대신 ./x로 실행하는 “x“라는 래퍼를 사용합니다.
빌드를 위한 설정을 만들어야 합니다. ./x setup을 사용하여 적절한 기본 설정을 만드십시오.
./x setup
그런 다음 컴파일러를 빌드할 수 있습니다. ./x build를 사용하여 컴파일러, 표준 라이브러리 및 몇 가지 도구를 빌드하십시오. ./x check로 검사만 할 수도 있습니다. 이 모든 명령은 특정 구성 요소/경로를 인자로 받을 수 있습니다. 예를 들어 컴파일러만 검사하려면 ./x check compiler를 사용하십시오.
./x build
컴파일러가 표준 라이브러리를 컴파일하는 방식에 영향을 주지 않는 변경(예를 들어 오류 메시지 변경)을 할 때는
--keep-stage-std 1을 사용하여 표준 라이브러리를 다시 컴파일하지 않도록 하십시오.
컴파일러와 표준 라이브러리를 빌드하고 나면 작동하는 컴파일러 툴체인이 준비됩니다. 이 툴체인을 링크하면 rustup과 함께 사용할 수 있습니다.
rustup toolchain link stage1 build/host/stage1
참고: ./x setup tools를 사용하면 기본 스테이지가 1이 아니라 2로 설정됩니다. 이에 맞게 명령을 조정하십시오:
rustup toolchain link stage2 build/host/stage2
이제 빌드에 링크된 stage1이라는 툴체인이 생겼습니다. 이 툴체인으로 컴파일러를 테스트할 수 있습니다.
rustc +stage1 testfile.rs
변경을 한 후에는 ./x test로 컴파일러 테스트 스위트를 실행할 수 있습니다.
./x test는 전체 테스트 스위트를 실행하므로 느리며, 이것이 필요한 경우는 드뭅니다. 컴파일러를 변경한 후에는 보통 ./x test tests/ui가 적합합니다. 이 명령은 특정 테스트 파일에 대해 컴파일러를 실행하고 출력을 확인하는 모든 UI 테스트를 수행합니다.
./x test tests/ui
변경을 한 뒤 .stderr 파일을 새 출력으로 갱신하려면 --bless를 사용하십시오.
축하합니다. 이제 컴파일러를 변경할 준비가 되었습니다! 궁금한 점이 더 있다면 전체 장에서 답을 찾을 수 있을 것이며, 답이 없다면 언제든 Zulip에서 도움을 요청하십시오.
VSCode, Vim, Emacs, Helix 또는 Zed를 사용한다면 ./x setup이 편집기 설정을 구성할지 묻습니다. 자세한 내용은 권장 워크플로를 확인하십시오.
사전 요구 사항
의존성
rust-lang/rust INSTALL을 참조하십시오.
하드웨어
빌드하려면 인터넷 연결이 필요합니다. 부트스트래핑 과정에서는 git 서브모듈을 업데이트하고 베타 컴파일러를 내려받습니다. 연결이 아주 빠를 필요는 없지만, 빠르면 도움이 됩니다.
엄격한 하드웨어 요구 사항은 없지만, 컴파일러 빌드는 계산 비용이 크므로 성능이 좋은 머신일수록 도움이 됩니다. 라즈베리 파이에서 빌드하는 것은 권장하지 않습니다! 다음 사양을 권장합니다.
- 30GB 이상의 여유 디스크 공간. 그렇지 않으면 증분 캐시를 계속 지워야 합니다. 공간은 많을수록 좋습니다. 컴파일러가 공간을 꽤 많이 차지하는데, 저희도 알고 있는 문제입니다.
- 8GB 이상의 RAM
- 2개 이상의 코어. 코어가 많을수록 정말 도움이 됩니다. 10개나 20개, 혹은 그 이상도 결코 많지 않습니다!
성능이 좋은 머신일수록 빌드가 훨씬 빨라집니다. 머신 성능이 그다지 좋지 않다면, 로컬 머신에서는 ./x check만 사용하고 PR 브랜치에 푸시할 때 CI 빌드가 변경 사항을 테스트하도록 하는 것이 일반적인 전략입니다.
적당한 성능의 노트북에서는 컴파일러를 빌드하는 데 30분 넘게 걸립니다. LLVM을 소스에서 직접 빌드하지 않아도 되도록 CI에서 내려받는 것을 권장합니다(여기를 참조하십시오).
cargo와 마찬가지로 빌드 시스템은 가능한 한 많은 코어를 사용합니다. 이로 인해 메모리가 부족해지는 경우가 있습니다. -j를 사용하여 동시 작업 수를 조정할 수 있습니다. 전체 빌드에 약 45분에서 1시간 넘게 걸린다면, 대부분의 시간을 메모리 스와핑에 쓰고 있을 가능성이 높습니다. 이 경우 -j1을 사용해 보십시오.
여유 디스크 공간이 넉넉하지 않다면 증분 컴파일을 끄는 것이 좋을 수 있습니다(여기를 참조하십시오). 이렇게 하면 컴파일 시간이 더 길어지지만(특히 리베이스 후에), 증분 캐시가 차지하는 공간을 크게 절약할 수 있습니다.
권장 워크플로
전체 부트스트래핑 과정은 시간이 꽤 오래 걸립니다. 작업을 더 편하게 해 줄 몇 가지 방법을 소개합니다.
pre-push 훅 설치하기
코드 품질을 보장하기 위한 내부 도구인 tidy를 통과하지 못하면 CI가 자동으로 빌드를 실패 처리합니다. 원하는 경우 푸시할 때마다 ./x test tidy를 자동으로 실행하는 Git 훅을 설치하여 코드가 기준을 충족하는지 확인할 수 있습니다. 훅이 실패하면 ./x test tidy --bless를 실행한 뒤 변경 사항을 커밋하십시오. 나중에 pre-push 동작이 바람직하지 않다고 판단되면 .git/hooks에 있는 pre-push 파일을 삭제하면 됩니다.
미리 만들어 둔 git 훅이 src/etc/pre-push.sh에 있습니다. 이 파일을 .git/hooks 폴더에 pre-push라는 이름으로 복사하면 됩니다(.sh 확장자는 빼야 합니다!).
./x setup을 실행하는 과정에서 훅을 설치할 수도 있습니다!
설정 확장
여러 작업을 하다 보면 서로 다른 부트스트랩 설정 사이를 전환해야 할 때가 있습니다. 이전 설정을 나중에 다시 쓰려고 보관해 두고 싶을 때도 있습니다. 하지만 원본 설정 값을 아무 파일에나 저장해 두고 직접 복사해 붙여 넣다 보면 금세 지저분해질 수 있으며, 여러 설정을 오랫동안 사용해 왔다면 더욱 그렇습니다.
여러 설정을 더 쉽게 관리하려면 설정 확장을 만들 수 있습니다.
예를 들어 cross.toml이라는 간단한 설정 파일을 만들 수 있습니다:
[build]
build = "x86_64-unknown-linux-gnu"
host = ["i686-unknown-linux-gnu"]
target = ["i686-unknown-linux-gnu"]
[llvm]
download-ci-llvm = false
[target.x86_64-unknown-linux-gnu]
llvm-config = "/path/to/llvm-19/bin/llvm-config"
그런 다음 bootstrap.toml에 이 파일을 포함하십시오:
include = ["cross.toml"]
확장 안에 다른 확장을 재귀적으로 포함할 수도 있습니다.
참고: include 필드에서 재정의는 오른쪽에서 왼쪽 순서로 적용됩니다. 예를 들어 include = ["a.toml", "b.toml"]에서는 b.toml 확장이 a.toml을 재정의합니다. 또한 상위 확장은 항상 내부 확장을 재정의합니다.
rustc를 위한 rust-analyzer 설정하기
“library”(라이브러리) 트리 검사하기
“library”(라이브러리) 트리를 검사하려면 stage1 컴파일러가 필요하며, 이는 일부 컴퓨터에서는 무거운 작업이 될 수 있습니다. 이러한 이유로 bootstrap에는 rust.download-rustc를 사용할 수 없는 경우 “library”(라이브러리) 트리 검사를 건너뛰는 --skip-std-check-if-no-download-rustc라는 플래그가 있습니다. rust-analyzer 때문에 컴퓨터에 큰 부하가 걸리는 것을 피하고 싶다면 rust-analyzer 설정의 ./x check 명령에 --skip-std-check-if-no-download-rustc 플래그를 추가하면 됩니다.
프로젝트 로컬 rust-analyzer 설정
rust-analyzer는 파일을 저장할 때마다 코드를 검사하고 포맷하도록 도와줍니다. 기본적으로 rust-analyzer는 cargo check와 rustfmt 명령을 실행하지만, rustc를 작업할 때는 이 명령들을 재정의하여 이러한 도구를 작업에 더 알맞게 조정한 버전을 사용하게 할 수 있습니다. 사용자 정의 설정을 사용하면 rust-analyzer가 소스를 검사할 때는 ./x check를, 포맷할 때는 stage 0 rustfmt를 사용하게 할 수 있습니다.
기본 rust-analyzer.check.overrideCommand 명령줄은 저장소에 있는 모든 크레이트와 도구를 검사합니다. 특정 부분을 작업하고 있다면 작업 중인 부분만 검사하도록 명령을 재정의하여 검사 시간을 절약할 수 있습니다. 예를 들어 컴파일러를 작업하고 있다면 명령을 x check compiler --json-output으로 재정의하여 컴파일러 부분만 검사할 수 있습니다. x check --help --verbose를 실행하면 사용할 수 있는 부분을 확인할 수 있습니다.
./x setup editor를 실행하면 지원되는 편집기 중 하나에 대한 프로젝트 로컬 LSP 설정 파일을 만들라는 안내가 표시됩니다. ./x setup을 실행하는 과정의 한 단계로 설정 파일을 만들 수도 있습니다.
rust-analyzer에 공유 빌드 디렉터리 사용하기
기본적으로 rust-analyzer가 bootstrap 명령을 실행할 때는 명령줄에서 수동으로 실행하는 빌드와 별개의 빌드 디렉터리를 사용합니다. 디스크 공간을 절약하려면 생성된 LSP 설정 파일에서 이 설정을 재정의할 수 있습니다.
그러나 다음과 같은 이유로 권장하지 않습니다.
- 각 빌드가 빌드 디렉터리를 잠가 다른 빌드를 기다리게 하므로, rust-analyzer가 백그라운드에서 명령을 실행하는 동안에는 명령줄 빌드를 실행할 수 없게 됩니다.
- 컴파일러 플래그나 기타 설정이 서로 충돌하여 한 빌드가 이전에 빌드된 산출물을 삭제할 위험이 커지며, 경우에 따라 추가로 다시 빌드해야 합니다.
Visual Studio Code
./x setup editor에서 vscode를 선택하면 Visual Studio Code를 구성하는 .vscode/settings.json 파일을 만들라는 안내가 표시됩니다. 권장 rust-analyzer 설정은 src/etc/rust_analyzer_settings.json에 있습니다.
저장할 때마다 ./x check를 실행하는 것이 불편하다면 VS Code에서는 대신 빌드 작업을 사용할 수 있습니다.
// .vscode/tasks.json
{
"version": "2.0.0",
"tasks": [
{
"label": "./x check",
"command": "./x check",
"type": "shell",
"problemMatcher": "$rustc",
"presentation": { "clear": true },
"group": { "kind": "build", "isDefault": true }
}
]
}
Neovim
Neovim 사용자에게는 몇 가지 선택지가 있습니다.
- 가장 쉬운 방법은 neoconf.nvim을 사용하는 것이지만, 이 플러그인은 더 이상 사용되지 않는
require('lspconfig')API를 사용하므로 neovim 0.11 이상에서 경고가 표시됩니다. coc.nvim을 사용하는 방법도 있지만, 이 경우 node.js가 설치되어 있어야 합니다.- 사용자 정의 스크립트로 rust-analyzer 설정을 불러오는 방법도 있습니다.
neoconf.nvim
neoconf.nvim을 사용하면 네이티브 LSP에서 프로젝트 로컬 설정 파일을 사용할 수 있습니다. 사용 방법은 아래와 같습니다. 이 단계를 진행하려면 rust-analyzer가 이미 Neovim에 구성되어 있어야 합니다. 구성 방법은 여기에서 확인할 수 있습니다.
- 먼저 플러그인을 설치하십시오. README에 안내된 단계를 따르면 설치할 수 있습니다.
./x setup editor를 실행하고vscode를 선택하여.vscode/settings.json파일을 만드십시오. 이 파일이 감지되면 프로젝트를 열 때neoconf가 rust-analyzer 설정을 자동으로 읽고 갱신할 수 있습니다. Neovim은 VS Code의${workspaceFolder}변수를 확장하지 않으므로, 생성된 파일에 있는 이 변수를 모두 rust 저장소의 절대 경로로 바꾸십시오.
coc.nvim
coc.nvim을 사용하는 경우 ./x setup editor를 실행하고 vim을 선택하면 .vim/coc-settings.json을 생성할 수 있습니다. 이 설정은 :CocLocalConfig로 편집할 수 있습니다. 권장 설정은 src/etc/rust_analyzer_settings.json에 있습니다.
사용자 지정 LSP 설정
neovim 0.11 이상을 사용하는 경우 nvim-lspconfig와 사용자 지정 스크립트만으로 rust-analyzer를 구성할 수 있습니다.
- rust-analyzer LSP가 설정되어 있는지 확인하십시오
- 다음 내용으로
$HOME/.config/nvim/after/plugged/rust_analyzer.lua를 생성하십시오:
-- Capture the default functions from nvim-lspconfig/lsp/rust_analyzer.lua before overriding it.
-- This file is in after/plugin to guarantee nvim-lspconfig has been initialised already.
local default_root_dir = vim.lsp.config["rust_analyzer"].root_dir
local default_before_init = vim.lsp.config["rust_analyzer"].before_init
vim.lsp.config("rust_analyzer", {
cmd = { "rust-analyzer" },
filetypes = { "rust" },
-- To support rust-lang/rust, we need to detect when we're in the rust repo and use the git root
-- instead of cargo project root.
root_dir = function(bufnr, on_dir)
local git_root = vim.fs.root(bufnr, { ".git" })
if git_root then
if vim.uv.fs_stat(vim.fs.joinpath(git_root, "src/etc/rust_analyzer_zed.json")) then
on_dir(git_root)
return
end
end
-- For anything that doesn't match rust-lang/rust, fallback to default root_dir
default_root_dir(bufnr, on_dir)
end,
before_init = function(init_params, config)
-- When inside rust-lang/rust, we need to use the special rust-analyzer settings.
local settings = vim.fs.joinpath(config.root_dir, "src/etc/rust_analyzer_zed.json")
if vim.uv.fs_stat(settings) then
local file = io.open(settings)
-- nvim 0.12+ supports comments otherwise you'll need content:gsub("//[^\n]*", "").
local json = vim.json.decode(file:read("*a"), { skip_comments = true })
file:close()
config.settings["rust-analyzer"] = vim.tbl_deep_extend(
"force", -- Overwrite with the special settings when there is a conflict.
config.settings["rust-analyzer"] or {},
json.lsp["rust-analyzer"].initialization_options
)
end
default_before_init(init_params, config)
end,
})
vim.lsp.enable("rust_analyzer")
위에서 설명한 빌드 작업을 사용하려면 설정에서 직접 명령을 만들거나, VSCode의 task.json 파일을 읽을 수 있는 overseer.nvim 같은 플러그인을 설치한 뒤 위와 동일한 안내를 따르면 됩니다.
Emacs
Emacs는 Eglot을 통해 프로젝트 로컬 설정과 함께 rust-analyzer를 지원합니다. rust-analyzer와 함께 Eglot을 설정하는 방법은 여기에서 확인할 수 있습니다. Rust 개발을 위해 Emacs와 Eglot을 전반적으로 설정했다면 ./x setup editor를 실행하고 emacs를 선택할 수 있으며, 그러면 Eglot용 권장 설정이 담긴 .dir-locals.el을 생성하라는 안내가 표시됩니다. 권장 설정은 src/etc/rust_analyzer_eglot.el에 있습니다. 프로젝트별 Eglot 설정에 관한 자세한 내용은 매뉴얼을 참고하십시오.
Helix
Helix는 LSP와 rust-analyzer 지원을 기본으로 제공합니다. 여기에 설명된 대로 languages.toml을 통해 구성할 수 있습니다. ./x setup editor를 실행하고 helix를 선택할 수 있으며, 그러면 Helix용 권장 설정이 담긴 languages.toml을 생성하라는 안내가 표시됩니다. 권장 설정은 src/etc/rust_analyzer_helix.toml에 있습니다.
Zed
Zed는 LSP와 rust-analyzer 지원을 기본으로 제공합니다. 여기에 설명된 대로 .zed/settings.json을 통해 구성할 수 있습니다. ./x setup editor에서 zed를 선택하면 Zed를 권장 설정으로 구성하는 .zed/settings.json 파일을 생성하라는 안내가 표시됩니다. 권장 rust-analyzer 설정은 src/etc/rust_analyzer_zed.json에 있습니다.
확인하고, 확인하고, 또 확인하십시오
간단한 리팩터링을 할 때는 ./x check를 계속 실행하는 것이 유용할 수 있습니다. 위에서 설명한 대로 rust-analyzer를 설정했다면 파일을 저장할 때마다 이 작업이 자동으로 수행됩니다. 여기서는 컴파일러가 빌드될 수 있는지만 확인하지만, 대개는 이것으로 충분합니다(예: 메서드 이름을 바꿀 때). 그런 다음 실제로 테스트를 실행해야 할 때 ./x build를 실행하면 됩니다.
사실, 코드가 동작할지 100% 확신하지 못할 때에도 테스트를 미루는 것이 유용한 경우가 있습니다. 그러면 리팩터링 커밋을 계속 쌓아 나가다가 나중에 한꺼번에 테스트를 실행할 수 있습니다. 그런 다음 git bisect를 사용하여 어느 커밋이 문제를 일으켰는지 정확하게 찾아낼 수 있습니다. 이 방식의 좋은 부수 효과는 마지막에 꽤 잘게 나뉜 커밋들이 남고, 그 커밋들이 모두 빌드되며 테스트를 통과한다는 점입니다. 이는 리뷰에 도움이 되는 경우가 많습니다.
nightly를 사용하도록 rustup 구성하기
부트스트랩 과정의 일부는 rustfmt 같은 도구의 고정된 nightly 버전을 사용합니다. 저장소에서 cargo fmt 같은 명령이 올바르게 동작하게 하려면, rustup으로 nightly 툴체인을 설치한 다음 이 명령을 실행하십시오:
cd <path to rustc repo>
rustup override set nightly
워크트리를 설정한 모든 디렉터리에 대해서도 이 작업을 잊지 마십시오. src/stage0에 지정된 고정 nightly 버전을 사용해야 할 수도 있지만, 대개는 일반 nightly 채널로도 동작합니다.
참고 x가 사용하는 이 실제 rustfmt로 vscode를 구성하는 방법은 vscode 관련 섹션을, 부트스트랩한 컴파일러용 rustup 툴체인을 설정하는 방법은 rustup 관련 섹션을 참고하십시오
참고 이렇게 해도 cargo로 rustc를 직접 빌드할 수 있게 되는 것은 아닙니다. 컴파일러나 표준 라이브러리 작업에는 여전히 x를 사용해야 하며, 이 설정은 단지 cargo fmt를 사용할 수 있게 해 줄 뿐입니다.
CI-rustc로 더 빠르게 빌드하기
컴파일러를 작업하는 것이 아니라면, 컴파일러 트리를 빌드할 필요가 없는 경우가 많습니다. 예를 들어, 컴파일러 빌드를 건너뛰고 library 트리나 src/tools 아래의 도구만 빌드할 수 있습니다. 이를 위해서는 구성에서 download-rustc 옵션을 설정하여 이 기능을 활성화해야 합니다. 이 옵션은 부트스트랩이 stage > 0 단계에서 최신 nightly 컴파일러를 사용하도록 지시합니다. 즉, stage0 컴파일러와 stage > 0 단계용 download-rustc 컴파일러라는 두 개의 미리 컴파일된 컴파일러를 갖게 됩니다. 이렇게 하면 트리 내 컴파일러를 빌드할 필요가 전혀 없습니다. 그 결과, 트리 내 컴파일러를 빌드하지 않으므로 빌드 시간이 크게 줄어듭니다.
--keep-stage-std로 더 빠르게 재빌드하기
때로는 컴파일러가 빌드되는지 확인하는 것만으로는 충분하지 않습니다. 흔한 예로, 어떤 상태의 값을 살펴보거나 문제를 더 잘 이해하기 위해 debug! 문을 추가해야 하는 경우가 있습니다. 그런 경우에는 전체 빌드가 굳이 필요하지 않습니다. 부트스트랩의 캐시 무효화를 우회하면 이러한 빌드를 더 빨리 끝낼 수 있는 경우가 많습니다. 유일한 단점은 약간의 편법이 필요하고 동작하지 않는 컴파일러가 만들어질 수 있다는 것입니다(다만 이는 쉽게 발견하고 고칠 수 있습니다).
필요한 명령의 순서는 다음과 같습니다:
- 최초 빌드:
./x build library - 이후 빌드:
./x build library --keep-stage-std=1
앞서 언급했듯이, --keep-stage-std=1의 효과는 기존 표준 라이브러리를 재사용할 수 있다고 그냥 가정하는 것입니다. 컴파일러를 수정하고 있다면 이 가정은 대개 맞습니다. 어쨌든 표준 라이브러리는 변경하지 않았기 때문입니다. 하지만 때로는 그렇지 않습니다. 예를 들어, 컴파일러가 타입과 기타 상태를 rlib 파일에 인코딩하는 방식을 제어하는 컴파일러의 “메타데이터” 부분을 수정하고 있거나, 메타데이터에 포함되는 것(MIR의 정의 등)을 수정하고 있는 경우입니다.
즉, --keep-stage-std=1을 사용하면 컴파일 과정에서 이상한 동작이 나타날 수 있습니다. 예를 들어 이상한 ICE나 기타 패닉이 발생할 수 있습니다. 그런 경우에는 명령에서 --keep-stage-std=1을 제거하고 다시 빌드하기만 하면 됩니다.
테스트를 실행할 때도 --keep-stage-std=1을 사용할 수 있습니다. 예를 들면 다음과 같습니다:
- 첫 테스트 실행:
./x test tests/ui - 이후 테스트 실행:
./x test tests/ui --keep-stage-std=1
증분 컴파일 사용하기
또한 --incremental 플래그를 활성화하면 이후 재빌드에서 시간을 더 절약할 수 있습니다:
./x test tests/ui --incremental --test-args issue-1234
매 명령마다 이 플래그를 붙이고 싶지 않다면 bootstrap.toml에서 활성화할 수 있습니다:
[rust]
incremental = true
증분 컴파일은 평소보다 디스크 공간을 더 많이 사용한다는 점에 유의하십시오. 디스크 공간이 걱정된다면 build 디렉터리의 크기를 가끔 확인하는 것이 좋습니다.
최적화 세부 조정하기
optimize = false로 설정하면 컴파일러가 테스트에 쓰기에는 너무 느려집니다. 하지만 테스트 주기를 개선하기 위해, 다시 빌드해야 하는 크레이트에 대해서만 선택적으로 최적화를 비활성화할 수 있습니다(출처). 예를 들어 rustc_mir_build를 작업할 때는 rustc_mir_build와 rustc_driver 크레이트의 증분 재빌드에 가장 많은 시간이 걸립니다. 따라서 루트 Cargo.toml에 다음과 같이 설정할 수 있습니다:
[profile.release.package.rustc_mir_build]
opt-level = 0
[profile.release.package.rustc_driver]
opt-level = 0
여러 브랜치에서 동시에 작업하기
여러 브랜치에서 병렬로 작업하는 것은 다소 번거로울 수 있습니다. 한 브랜치에서 컴파일러를 빌드하면 이전 빌드와 증분 컴파일 캐시를 덮어쓰기 때문입니다. 한 가지 해결책은 저장소를 여러 개 클론해 두는 것이지만, 그러면 Git 메타데이터를 여러 번 저장해야 하고 각 클론을 개별적으로 업데이트해야 합니다.
다행히 Git에는 워크트리라는 더 나은 해결책이 있습니다. 워크트리를 사용하면 모두 같은 Git 데이터베이스를 공유하는 여러 개의 “작업 트리“를 만들 수 있습니다. 게다가 모든 워크트리가 같은 객체 데이터베이스를 공유하므로, 어느 워크트리에서든 브랜치(예: main)를 업데이트하면 모든 워크트리에서 새 커밋을 사용할 수 있습니다. 다만 한 가지 주의할 점은 서브모듈은 공유되지 않는다는 것입니다. 서브모듈은 여전히 여러 번 클론됩니다.
Rust 저장소의 루트 디렉터리에 있다면, 다음 명령을 실행하여 새 “rust2” 디렉터리에 “연결된 작업 트리“를 만들 수 있습니다:
git worktree add ../rust2
main을 기반으로 한 새 브랜치용 워크트리를 새로 만드는 방법은 다음과 같습니다:
git worktree add -b my-feature ../rust2 main
이제 그 rust2 폴더를 rustc를 수정하고 빌드하기 위한 별도의 작업 공간으로 사용할 수 있습니다!
nix로 작업하기
src/tools/nix-dev-shell에 여러 nix 구성이 정의되어 있습니다.
direnv를 사용한다면 src/tools/nix-dev-shell/envrc-flake 또는 src/tools/nix-dev-shell/envrc-shell에 대한 심볼릭 링크를 만들 수 있습니다
ln -s ./src/tools/nix-dev-shell/envrc-flake ./.envrc # Use flake
또는
ln -s ./src/tools/nix-dev-shell/envrc-shell ./.envrc # Use nix-shell
flake를 사용한다면 다음 명령으로 flake도 반드시 업데이트하십시오:
nix flake update --flake ./src/tools/nix-dev-shell
이 셸은 모든 의존성이 올바르게 설정된 상태로 ./x.py 스크립트를 실행하는 x라는 명령을 생성합니다.
참고
NixOS가 아닌 배포판에서 nix를 사용할 때는 bootstrap.toml에서 build.patch-binaries-for-nix = true를 설정해야 할 수도 있다는 점에 유의하십시오. 부트스트랩은 nix 안에서 실행 중인지 감지하여 패치를 자동으로 활성화하려고 시도하지만, 이 감지에서 거짓 음성(false negative)이 발생할 수 있습니다.
nix 셸을 사용해 bootstrap.toml을 관리할 수도 있습니다:
let
config = pkgs.writeText "rustc-config" ''
# Your bootstrap.toml content goes here
''
pkgs.mkShell {
/* ... */
# This environment variable tells bootstrap where our bootstrap.toml is.
RUST_BOOTSTRAP_CONFIG = config;
}
셸 자동 완성
Bash, Zsh, Fish 또는 PowerShell을 사용한다면 src/etc/completions에서 ./x용으로 자동 생성된 셸 자동 완성 스크립트를 찾을 수 있습니다.
원하는 셸의 자동 완성을 불러오려면 source ./src/etc/completions/x.<extension>을 사용하고, PowerShell에서는 & .\src\etc\completions\x.ps1을 사용하면 됩니다. 이 명령을 셸의 시작 스크립트(예: .bashrc)에 추가하면 이 자동 완성이 자동으로 로드됩니다.
배포용 산출물 빌드하기
배포를 위해 컴파일러를 빌드하고 패키징해야 할 수도 있습니다. 이렇게 하려면 다음 명령을 실행하십시오:
./x dist
소스에서 설치하기
소스에서 빌드하여 Rust(및 구성 파일에 설정된 도구)를 설치하는 방식을 선호할 수도 있습니다. 그렇다면 다음 명령을 실행하십시오:
./x install
참고: 컴파일러에 대한 수정 사항을 테스트하고 있다면, (./x build로) 컴파일러를 빌드한 다음 여기에서 설명한 대로 툴체인을 만드는 것이 좋습니다.
예를 들어, 생성한 툴체인의 이름이 “foo“라면 rustc +foo ...로 호출합니다(여기서 …은 나머지 인자를 나타냅니다).
Rust(및 구성 파일에 있는 도구)를 전역으로 설치하는 대신, DESTDIR 환경 변수를 설정하여 설치 경로를 변경할 수 있습니다. 설치 경로를 더 동적으로 설정하려면 구성 파일의 설치 옵션을 사용하는 것이 좋습니다.
문서 빌드하기
이 장에서는 표준 라이브러리(std)나 컴파일러(rustc)와 같은 툴체인 구성 요소의 문서를 빌드하는 방법을 설명합니다.
-
모든 것 문서화하기
이 방법은 베타 툴체인의
rustdoc을 사용하므로, rustdoc이 활발히 개발 중인 관계로 stage 1 rustdoc과는 (약간) 다른 출력을 생성합니다../x doc문서가 CI에서와 동일하게 보이도록 확실히 하려면 다음과 같이 하십시오.
./x doc --stage 1이렇게 하면 (현재의) rustdoc이 먼저 빌드되고, 그 rustdoc을 사용하여 구성 요소를 문서화합니다.
-
개별 테스트를 실행하거나 특정 구성 요소를 빌드하는 것과 마찬가지로, 원하는 문서만 빌드할 수 있습니다.
./x doc src/doc/book ./x doc src/doc/nomicon ./x doc compiler library전체 책 목록은 nightly 문서 색인 페이지를 참고하십시오.
-
rustc 내부 항목 문서화하기
컴파일러 문서는 기본적으로 빌드되지 않습니다.
x doc으로 기본적으로 생성되도록 하려면bootstrap.toml을 수정하십시오.build.compiler-docs = true이 옵션을 활성화하면 컴파일러 내부 항목에 대한 문서도 함께 빌드된다는 점에 유의하십시오.
참고: 컴파일러 문서는 이 링크에서 찾을 수 있습니다.
문서에 기여하기
문서 개선은 언제나 환영합니다. doc.rust-lang.org의 소스는 트리 내의 src/doc에 있으며, 표준 API 문서는 소스 코드 자체(예: library/std/src/lib.rs)에서 생성됩니다. 문서 관련 풀 리퀘스트도 다른 풀 리퀘스트와 같은 방식으로 함수처럼 동작합니다.
문서 관련 이슈를 찾으려면 A-docs 레이블을 사용하십시오.
문서 스타일 가이드라인은 RFC 1574에서 확인할 수 있습니다.
표준 라이브러리 문서를 빌드하려면 x doc --stage 1 library --open을 사용하십시오. 책(예: unstable book)의 문서를 빌드하려면 x doc src/doc/unstable-book을 사용하십시오. 결과물은 build/host/doc에 생성되며, 기본 브라우저에서도 자동으로 열립니다. 자세한 내용은 문서 빌드하기를 참고하십시오.
작은 수정 사항을 확인할 때는 rustdoc을 직접 사용할 수도 있습니다. 예를 들어, rustdoc src/doc/reference.md는 reference를 doc/reference.html로 렌더링합니다. CSS가 깨질 수는 있지만, HTML이 올바른지는 확인할 수 있습니다.
내부 문서에 대한 타이포그래피/맞춤법 수정은 대개 변경으로 인한 부담이나 리뷰 시간을 들일 만한 가치가 없으므로 받지 않는다는 점에 유의하십시오. 내부 문서의 예로는 코드 주석과 rustc API 문서가 있습니다. 다만 같은 PR에서 다른 개선 사항과 함께 수정하는 경우라면 얼마든지 수정하셔도 됩니다.
Rustdoc 개요
rustdoc는 컴파일러 및 표준 라이브러리와 함께 같은 소스 트리 안에 있습니다. 이 장에서는 rustdoc가 어떻게 동작하는지 설명합니다. Rustdoc의 기능과 사용법에 대한 정보는 Rustdoc 책을 참고하십시오. rustdoc의 동작 방식에 대한 자세한 내용은 “Rustdoc 내부 구조” 장을 참고하십시오.
rustdoc는 rustc 내부 구조(그리고 당연히 표준 라이브러리)를 사용하므로, rustdoc를 빌드하려면 먼저 컴파일러와 std를 한 번 빌드해야 합니다.
Rustdoc는 전적으로 librustdoc 크레이트 안에 구현되어 있습니다. Rustdoc는 크레이트의 내부 표현(HIR)을 얻고 아이템의 타입에 관한 몇 가지 쿼리를 실행할 수 있는 시점까지 컴파일러를 실행합니다. HIR과 쿼리는 링크된 장에서 다룹니다.
그 후 librustdoc는 문서를 렌더링하기 위해 두 가지 주요 단계를 수행합니다.
- AST를 문서 생성에 더 적합한 형태(그리고 컴파일러의 잦은 변경에 조금 더 강한 형태)로 “정리(clean)“합니다.
- 이렇게 정리된 AST를 사용하여 크레이트의 문서를 한 페이지씩 렌더링합니다.
물론 실제로는 이것보다 더 많은 일이 일어나며 위 설명은 많은 세부 사항을 생략한 것이지만, 개략적인 개요는 이와 같습니다.
(참고: librustdoc는 라이브러리 크레이트입니다! rustdoc 바이너리는 src/tools/rustdoc에 있는 프로젝트를 사용하여 만들어집니다. 다만 그 프로젝트가 하는 일은 말 그대로 이 크레이트의 lib.rs에 있는 main()을 호출하는 것뿐이라는 점에 유의하십시오.)
치트 시트
- 시작하기 전에
./x setup tools를 실행하십시오. 이 명령은 rustdoc 및 기타 도구 개발에 알맞은 설정으로x를 구성하며, 여기에는 rustc를 직접 빌드하는 대신 사본을 내려받는 설정도 포함됩니다. - 컴파일 오류를 빠르게 확인하려면
./x check rustdoc를 사용하십시오. - 다른 프로젝트에서 실행할 수 있는 rustdoc를 만들려면
./x build library rustdoc를 사용하십시오.rustdoc --test를 사용하려면library/test를 추가하십시오.- rustup 환경에
stage2라는 사용자 정의 툴체인을 추가하려면rustup toolchain link stage2 build/host/stage2를 실행하십시오. 이 명령을 실행한 후에는 어느 디렉터리에서든cargo +stage2 doc를 실행하면 로컬에서 컴파일한 rustdoc로 빌드합니다.
- 이 rustdoc로 표준 라이브러리 문서를 생성하려면
./x doc library를 사용하십시오.- 완성된 문서는
build/host/doc(core,alloc,std아래)에서 확인할 수 있습니다. - 이 문서를 웹 서버에 복사하려면
build/host/doc전체를 복사하십시오. CSS, JS, 글꼴, 랜딩 페이지가 모두 그곳에 있기 때문입니다. - 프런트엔드를 디버깅할 때는
bootstrap.toml에서rust.docs-minification옵션을 비활성화하십시오.
- 완성된 문서는
- stage1 rustdoc로 테스트를 실행하려면
./x test tests/rustdoc*를 사용하십시오.- 테스트에 대한 자세한 내용은 Rustdoc 내부 구조를 참고하십시오.
- rustdoc의 JavaScript 검사(
eslint,es-check,tsc)를 실행하려면./x test tidy --extra-checks=js를 사용하십시오.
참고:
./x test tidy는 JS/TS 소스가 변경되면 이미 이 검사들을 자동으로 실행합니다.--extra-checks=js는 이 검사들을 명시적으로 강제 실행합니다.
JavaScript CI 검사
Rustdoc의 JavaScript와 TypeScript는 CI 과정에서 compiletest가 아니라 eslint, es-check, tsc로 검사합니다. 이 검사들은 tidy 작업의 일부로 실행됩니다.
./x test tidy --extra-checks=js
--extra-checks=js 플래그를 사용하면 CI에서 실행되는 프런트엔드 린트를 활성화합니다.
코드 구조
이 절의 모든 경로는 rust-lang/rust 저장소의 src/librustdoc/를 기준으로 한 상대 경로입니다.
- HTML 출력 코드의 대부분은
html/format.rs와html/render/mod.rs에 있습니다. 이 코드는impl std::fmt::Display를 반환하는 여러 함수로 이루어져 있습니다. - 위에서 언급한 함수들이 렌더링하는 데이터 타입은
clean/types.rs에 정의되어 있습니다.HIR와rustc_middle::tyIR로부터 이 타입들을 생성하는 함수는clean/mod.rs에 있습니다. - rustdoc을 테스트 하네스로 사용하는 데 특화된 부분은
doctest.rs에 있습니다. - Markdown 렌더러는
html/markdown.rs에서 로드되며, 여기에는 주어진 Markdown 블록에서 doctest를 추출하는 함수도 포함되어 있습니다. - 프런트엔드 CSS와 JavaScript는
html/static/에 저장되어 있습니다.- 참고로, JavaScript의 경우 타입 주석은 TypeScript 방식의 JSDoc 주석과 외부
.d.ts파일을 사용하여 작성합니다. 이렇게 하면 코드 자체는 평범하고 유효한 JavaScript로 유지됩니다.tsc는 린터로만 사용합니다.
- 참고로, JavaScript의 경우 타입 주석은 TypeScript 방식의 JSDoc 주석과 외부
테스트
rustdoc의 통합 테스트는 여러 테스트 스위트에 나뉘어 있습니다. 자세한 내용은 Rustdoc 테스트 스위트를 참고하십시오.
제약
JavaScript가 비활성화된 경우와 로컬 파일을 탐색하는 경우에도 rustdoc이 적절히 동작하도록 노력하고 있습니다. 지원하는 브라우저 목록이 있습니다.
로컬 파일(file:/// URL)을 지원하면 예상치 못한 제약이 몇 가지 생깁니다. localStorage나 Service Worker처럼 보안 출처(secure origin)가 필요한 일부 브라우저 기능은 안정적으로 동작하지 않습니다. 이러한 기능을 여전히 사용할 수는 있지만, 그 기능이 없어도 페이지를 사용할 수 있도록 해야 합니다.
Rustdoc은 함수 본문의 타입 검사를 하지 않습니다. 이는 typeck을 위한 내장 쿼리를 재정의하고, 이름 해석 오류를 무시하며, 불투명 타입을 해석하지 않는 방식으로 동작합니다. 여기에는 몇 가지 주의 사항이 따릅니다. 특히 rustdoc은 본문의 타입 검사가 필요한 컴파일러의 어떤 부분도 실행할 수 없습니다. 예를 들어 .rlib 파일을 생성하거나 대부분의 린트를 실행할 수 없습니다. 언젠가는 이 모델에서 벗어나고자 하지만, 이 모델을 사용하는 사람들을 위한 대안이 필요합니다. 여러 이전 Zulip 논의를 참고하십시오. 이 우회 방식을 제거하면 동작하지 않는 코드의 예시는 tests/rustdoc-ui/error-in-impl-trait를 참고하십시오.
동일한 출력 디렉터리에서 여러 번 실행하기
Rustdoc은 출력 디렉터리를 동일하게 설정한 채로 서로 다른 입력에 대해 여러 번 실행할 수 있습니다. cargo는 이 방식으로 현재 크레이트의 의존성 문서를 생성합니다. 사용자가 관심 있는 문서를 모두 담은 큰 문서 묶음을 원한다면 이 작업을 수동으로 할 수도 있습니다.
HTML은 크레이트마다 독립적으로 생성되지만, 출력 디렉터리에 크레이트를 추가할 때마다 갱신하는 크레이트 간 정보도 일부 있습니다.
crates<SUFFIX>.js에는 출력 디렉터리에 있는 모든 크레이트의 목록이 들어 있습니다.search-index<SUFFIX>.js에는 검색 가능한 모든 항목의 목록이 들어 있습니다.- 각 트레이트마다
implementors/.../trait.TraitName.js아래에 해당 트레이트 구현자의 목록을 담은 파일이 있습니다. 구현자는 트레이트와 다른 크레이트에 있을 수 있으며, 새 구현자를 발견할 때마다 JS 파일이 갱신됩니다.
사용 사례
rustdoc 작업 시 염두에 두어야 할 주요 사용 사례가 몇 가지 있습니다.
표준 라이브러리 문서
이 문서는 Rust 릴리스 과정의 일부로 https://doc.rust-lang.org/std에 게시됩니다. 안정 릴리스는 https://doc.rust-lang.org/1.57.0/std/처럼 버전이 지정된 특정 URL에도 업로드됩니다. 베타 문서와 나이틀리 문서는 https://doc.rust-lang.org/beta/std/와 https://doc.rust-lang.org/nightly/std/에 게시됩니다. 문서는 promote-release 도구로 업로드되며 CloudFront를 통해 S3에서 제공됩니다.
표준 라이브러리 문서에는 alloc, core, proc_macro, std, test의 다섯 크레이트가 포함되어 있습니다.
docs.rs
크레이트가 crates.io에 게시되면 docs.rs가 자동으로 해당 문서를 빌드하여 게시합니다. 예를 들어 https://docs.rs/serde/latest/serde/에 게시됩니다. docs.rs는 항상 현재 nightly rustdoc으로 빌드하므로, rustdoc에 반영한 모든 변경은 docs.rs에 즉시 공개적으로 영향을 미친다는 점에서 “즉시 안정(insta-stable)“합니다. 오래된 문서는 가끔씩만 다시 빌드되므로, docs.rs에서 오래된 릴리스를 둘러보면 UI가 조금씩 다르다는 것을 알 수 있습니다. 크레이트 작성자는 재빌드를 요청할 수 있으며, 재빌드는 최신 rustdoc으로 실행됩니다.
Docs.rs는 저장 공간을 절약하고 상단에 탐색 바를 표시하기 위해 rustdoc의 출력에 몇 가지 변환을 수행합니다. 특히 main.js나 rustdoc.css 같은 일부 정적 파일은 같은 버전의 rustdoc을 여러 번 호출할 때 공유될 수 있습니다. crates.js나 sidebar-items.js 같은 다른 파일은 호출마다 달라집니다. 글꼴 같은 나머지 파일은 결코 변경되지 않습니다. 이러한 범주는 src/librustdoc/html/render/write_shared.rs에 있는 SharedResource 열거형으로 구분됩니다.
docs.rs의 문서는 항상 한 번에 하나의 크레이트에 대해서만 생성되므로, 검색 기능과 사이드바 기능에는 현재 크레이트의 의존성이 포함되지 않습니다.
로컬에서 생성한 문서
크레이트 작성자는 로컬에 체크아웃한 크레이트에서 cargo doc --open을 실행하여 문서를 볼 수 있습니다. 이는 작성 중인 문서가 유용한지, 올바르게 표시되는지 확인하는 데 유용합니다. 직접 작성하지는 않았지만 사용하려는 크레이트의 문서를 보려는 사람에게도 유용할 수 있습니다. 두 경우 모두 --document-private-items Cargo 플래그를 사용하면 평소에는 표시되지 않는 비공개 메서드, 필드 등을 볼 수 있습니다.
기본적으로 cargo doc은 크레이트와 그 모든 의존성의 문서를 생성합니다. 이로 인해 문서 번들이 매우 커지고, 검색 코퍼스도 커져 검색이 느려질 수 있습니다. Cargo 플래그 --no-deps를 사용하면 이 동작을 막고 해당 크레이트의 문서만 생성합니다.
자체 호스팅 프로젝트 문서
일부 프로젝트는 자체적으로 문서를 호스팅합니다. 로컬에서 문서를 생성한 뒤 웹 서버에 복사하기만 하면 되므로 쉽게 할 수 있습니다. Rustdoc의 HTML 출력은 플래그를 통해 폭넓게 사용자 정의할 수 있습니다. 사용자는 테마를 추가하거나, 기본 테마를 설정하거나, 임의의 HTML을 삽입할 수 있습니다. 자세한 내용은 rustdoc --help를 참조하십시오.
새 타깃 추가하기
다음은 새 타깃에 대한 지원을 추가하는 일련의 단계입니다. 도달할 수 있는 최종 상태와 그에 이르는 경로가 다양하므로, 모든 절이 원하는 목표와 관련이 있지는 않을 수 있습니다.
타깃 등급 정책의 관련 문서도 참고하십시오.
새 LLVM 지정하기
아주 새로운 타깃의 경우, 현재 Rust와 함께 배포되는 것과는 다른 LLVM 포크를 사용해야 할 수도 있습니다. 이 경우 src/llvm-project git 서브모듈로 이동하여(서브모듈이 갱신되도록 ./x check를 적어도 한 번 실행해야 할 수도 있습니다) 사용하는 포크에 맞는 커밋을 체크아웃한 다음, 그 새 서브모듈 참조를 메인 Rust 저장소에 커밋하십시오.
예시는 다음과 같습니다.
cd src/llvm-project
git remote add my-target-llvm some-llvm-repository
git checkout my-target-llvm/my-branch
cd ..
git add llvm-project
git commit -m 'Use my custom LLVM'
미리 빌드된 LLVM 사용하기
이미 빌드된 로컬 LLVM 체크아웃이 있다면, 중복 빌드를 피하기 위해 해당 빌드를 시스템 LLVM으로 취급하도록 Rust를 설정할 수 있습니다.
bootstrap.toml의 target 섹션을 사용하여 Rust가 미리 빌드된 버전의 LLVM을 사용하도록 지정할 수 있습니다.
[target.x86_64-unknown-linux-gnu]
llvm-config = "/path/to/llvm/llvm-7.0.1/bin/llvm-config"
시스템 LLVM을 사용하려는 경우, 이전에 다음과 같은 경로가 확인된 적이 있습니다. 다만 여러분의 시스템에서는 다를 수 있습니다.
/usr/bin/llvm-config-8/usr/lib/llvm-8/bin/llvm-config
코드 생성 테스트에 사용되는 LLVM FileCheck 도구가 설치되어 있어야 한다는 점에 유의하십시오. 이 도구는 보통 LLVM과 함께 빌드되지만, 직접 미리 설치한 LLVM을 사용하는 경우에는 FileCheck를 다른 방법으로 제공해야 합니다. Debian 기반 시스템에서는 llvm-N-tools 패키지를 설치할 수 있습니다(N은 LLVM 버전 번호이며, 예를 들면 llvm-8-tools입니다). 또는 bootstrap.toml의 llvm-filecheck 설정 항목으로 FileCheck의 경로를 지정하거나, bootstrap.toml의 rust.codegen-tests 항목으로 코드 생성 테스트를 비활성화할 수 있습니다.
타깃 명세 만들기
먼저 타깃 JSON 파일부터 시작해야 합니다. --print target-spec-json을 사용하면 기존 타깃의 명세를 볼 수 있습니다.
rustc -Z unstable-options --target=wasm32-unknown-unknown --print target-spec-json
해당 JSON을 파일로 저장하고 여러분의 타깃에 맞게 적절히 수정하십시오.
타깃 명세 추가하기
JSON 명세를 작성하고 어느 정도 성공적으로 컴파일할 수 있게 되면, 그 명세를 컴파일러 자체에 복사해 넣을 수 있습니다.
rustc_target::spec 모듈에 있는 supported_targets 매크로 내부의 큰 테이블에 한 줄을 추가해야 합니다. 그런 다음 target 함수를 담은, 새 타깃에 대응하는 파일을 추가합니다.
예시로 삼을 만한 기존 타깃을 찾아보십시오.
부트스트랩에서 이 타깃을 사용하려면 src/bootstrap/src/core/sanity.rs의 STAGE0_MISSING_TARGETS 목록에 타깃 트리플을 명시적으로 추가해야 합니다. 기본 부트스트랩 컴파일러(일반적으로 베타 컴파일러)가 방금 추가한 새 타깃을 인식하지 못하기 때문에 이 작업이 필요합니다. 따라서 이 타깃이 아직 stage0 컴파일러에서 지원되지 않는다는 것을 부트스트랩이 알 수 있도록 STAGE0_MISSING_TARGETS에 추가해야 합니다.
const STAGE0_MISSING_TARGETS: &[&str] = &[
+ "NEW_TARGET_TRIPLE"
];
크레이트 패치하기
컴파일러가 의존하는 libc나 cc 같은 크레이트를 변경해야 할 수도 있습니다. 그런 경우 Cargo의 [patch] 기능을 사용할 수 있습니다. 예를 들어 아직 릴리스되지 않은 버전의 libc를 사용하려면 최상위 Cargo.toml 파일에 다음과 같이 추가하면 됩니다.
diff --git a/Cargo.toml b/Cargo.toml
index 1e83f05e0ca..4d0172071c1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -113,6 +113,8 @@ cargo-util = { path = "src/tools/cargo/crates/cargo-util" }
[patch.crates-io]
+libc = { git = "https://github.com/rust-lang/libc", rev = "0bf7ce340699dcbacabdf5f16a242d2219a49ee0" }
# See comments in `src/tools/rustc-workspace-hack/README.md` for what's going on
# here
rustc-workspace-hack = { path = 'src/tools/rustc-workspace-hack' }
그런 다음 cargo update -p libc를 실행하여 lockfile을 갱신하십시오.
로컬 path 의존성으로 패치하면 해당 의존성에 대한 경고가 활성화된다는 점에 주의하십시오. 일부 의존성은 경고가 없는 상태가 아니므로, bootstrap.toml의 rust.deny-warnings 설정 때문에 빌드가 갑자기 실패하기 시작할 수 있습니다. 경고를 해결하려면 다음과 같이 할 수 있습니다.
- 의존성을 수정하여 경고를 제거합니다
- 또는 로컬 개발 목적이라면 bootstrap.toml에서
rust.deny-warnings = false를 설정하여 경고를 억제합니다.
크로스 컴파일
JSON과 코드에 타깃 명세를 마련했다면 rustc를 크로스 컴파일할 수 있습니다.
DESTDIR=/path/to/install/in \
./x install -i --stage 1 --host aarch64-apple-darwin.json --target aarch64-apple-darwin \
compiler/rustc library/std
대상 명세가 부트스트랩 컴파일러에 이미 있다면, 두 인자 모두에 JSON 파일 대신 해당 명세를 사용할 수 있습니다.
타깃을 tier 2(타깃)에서 tier 2(호스트)로 승격하기
tier 2 타깃에는 두 가지 수준이 있습니다.
- 크로스 컴파일만 되는 타깃(
rustup target add) - 네이티브 툴체인이 있는 타깃(
rustup toolchain install)
크로스 컴파일 타깃을 네이티브 타깃으로 승격한 예시는 #75914를 참고하십시오.
컴파일러 최적화 빌드
가능한 한 최적화된 rustc 빌드를 컴파일하는 데 사용할 수 있는 추가 빌드 구성 옵션과 기법이 여러 가지 있습니다(예: Linux 배포판용 rustc를 빌드하는 경우). 여러 Rust 타깃에서 이러한 구성 옵션을 지원하는 현황은 여기에서 추적하고 있습니다. 이 페이지에서는 rustc를 직접 빌드할 때 이러한 방법을 사용하는 방법을 설명합니다.
링크 타임 최적화
링크 타임 최적화는 프로그램 성능을 높일 수 있는 강력한 컴파일러 기법입니다. rustc를 빌드할 때 (Thin-)LTO를 활성화하려면 bootstrap.toml에서 rust.lto 구성 옵션을 "thin"으로 설정하십시오:
rust.lto = "thin"
rustc의 LTO는 현재x86_64-unknown-linux-gnu타깃에서만 지원되고 테스트된다는 점에 유의하십시오. 다른 타깃에서도 동작할 수는 있지만, 동작이 보장되지는 않습니다. 특히 LTO로 최적화된rustc는 현재 Windows에서 잘못된 컴파일을 일으킵니다.
Linux에서 LTO를 활성화하여 최대 10%의 속도 향상을 얻은 바 있습니다.
메모리 할당자
rustc에 다른 메모리 할당자를 사용하면 상당한 성능 이점을 얻을 수 있습니다. jemalloc 할당자를 활성화하려면 bootstrap.toml에서 build.allocator 옵션을 jemalloc으로 설정하면 됩니다:
build.allocator = "jemalloc"
이 옵션은 현재 Linux와 macOS 타깃에서만 지원된다는 점에 유의하십시오.
코드 생성 단위
rustc 크레이트당 코드 생성 단위 수를 줄이면 더 빠른 컴파일러 빌드를 만들 수 있습니다. bootstrap.toml에서 다음 옵션으로 rustc와 libstd의 코드 생성 단위 수를 변경할 수 있습니다:
rust.codegen-units = 1
rust.codegen-units-std = 1
명령어 집합
기본적으로 rustc는 가능한 한 많은 CPU를 지원하기 위해 (선택한 타깃에 따라) 제네릭한(그리고 보수적인) 명령어 집합 아키텍처를 대상으로 컴파일됩니다. 특정 명령어 집합 아키텍처를 대상으로 rustc를 컴파일하려면 RUSTFLAGS에서 target_cpu 컴파일러 옵션을 설정하면 됩니다:
RUSTFLAGS="-C target_cpu=x86-64-v3" ./x build ...
LLVM도 특정 명령어 집합을 대상으로 컴파일하려면 bootstrap.toml에서 llvm 플래그를 설정하면 됩니다:
llvm.cxxflags = "-march=x86-64-v3"
llvm.cflags = "-march=x86-64-v3"
프로파일 기반 최적화
프로파일 기반 최적화(더 일반적으로는 피드백 기반 최적화)를 적용하면 rustc의 성능을 최대 15%까지 크게 높일 수 있습니다(1, 2). 하지만 이러한 기법은 구성 옵션 하나로 간단히 활성화되지 않으며, rustc를 여러 번 컴파일하고 선택한 벤치마크에서 프로파일링하는 복잡한 빌드 워크플로가 필요합니다.
최종 사용자에게 배포하는 빌드를 위해 PGO(프로파일 기반 최적화)와 BOLT(링크 후 바이너리 최적화 도구)로 rustc를 최적화하는 데 사용하는 opt-dist라는 도구가 있습니다. src/tools/opt-dist에 있는 이 도구를 살펴보고 이를 바탕으로 사용자 정의 PGO 빌드 워크플로를 구성하거나, 도구를 직접 사용해 볼 수 있습니다. 이 도구는 현재 Rust의 지속적 통합 워크플로에서 사용하는 방식에 맞춰 상당 부분 하드코딩되어 있으므로, 다른 환경에서 동작하게 하려면 별도의 수정이 필요할 수 있다는 점에 유의하십시오.
이 도구를 사용하려면 몇 가지 외부 의존성을 준비해야 합니다:
- Python3 인터프리터(
x.py실행용) llvm-profdata바이너리가 포함된 컴파일된 LLVM 툴체인 선택적으로 BOLT를 사용하려면 툴체인에llvm-bolt및merge-fdata바이너리가 있어야 합니다.
이러한 의존성은 Environment 구조체의 구현을 통해 opt-dist에 제공됩니다. 이 구현은 PGO/BOLT 파이프라인이 수행될 디렉터리와 Python이나 LLVM 같은 외부 의존성을 지정합니다.
다음은 opt-dist를 로컬에서(CI 외부에서) 사용하는 방법의 예시입니다:
opt-dist는 메트릭이 활성화되어 있다고 가정하므로bootstrap.toml파일에서 메트릭을 활성화하십시오:build.metrics = true- 다음 명령으로 도구를 빌드하십시오:
./x build tools/opt-dist local모드로 도구를 실행하고 필요한 매개변수를 제공하십시오:./build/host/stage1-tools-bin/opt-dist local \ --target-triple <target> \ # select target, e.g. "x86_64-unknown-linux-gnu" --checkout-dir <path> \ # path to rust checkout, e.g. "." --llvm-dir <path> \ # path to built LLVM toolchain, e.g. "/foo/bar/llvm/install" -- ./x dist # pass the actual build command--help를 실행하면 수정할 수 있는 추가 매개변수를 확인할 수 있습니다.
참고:
opt-dist를 로컬에서 실행하는 대신 실제 CI 파이프라인을 실행하려면cargo run --manifest-path src/ci/citool/Cargo.toml run-local dist-x86_64-linux를 실행하면 됩니다.
컴파일러 테스트하기
Rust 프로젝트는 빌드 시스템(./x test)이 조율하는 매우 다양한 테스트를 실행합니다. 이 절에서는 여러 테스트 도구를 간략히 소개합니다. 이어지는 장에서는 테스트 실행하기와 새 테스트 추가하기를 자세히 다룹니다.
테스트의 종류
Rust 배포판의 여러 부분을 검증하기 위한 테스트에는 여러 종류가 있습니다. 아래에 언급한 몇 가지 예외를 제외하면 거의 모든 테스트는 ./x test로 실행됩니다.
Compiletest
컴파일러 자체를 테스트하는 주요 테스트 하네스는 compiletest라는 도구입니다.
compiletest는 테스트 스위트로 구성된 여러 형태의 테스트 실행을 지원합니다. 테스트 모드는 일련의 테스트 스위트에 공통 프리셋/동작을 제공할 수 있습니다. compiletest가 지원하는 테스트는 tests 디렉터리에 있습니다.
Compiletest 장에서 이 도구의 사용법을 자세히 설명합니다.
예:
./x test tests/ui
패키지 테스트
표준 라이브러리와 여러 컴파일러 패키지에는 일반적인 Rust #[test] 단위 테스트, 통합 테스트, 문서 테스트가 들어 있습니다. library/ 또는 compiler/ 디렉터리에 있는 거의 모든 패키지의 경로를 ./x test에 전달할 수 있으며, 그러면 x는 사실상 해당 패키지에 대해 cargo test를 실행합니다.
예시:
| 명령 | 설명 |
|---|---|
./x test library/std | std에 대해서만 테스트를 실행합니다 |
./x test library/core | core에 대해서만 테스트를 실행합니다 |
./x test compiler/rustc_data_structures | rustc_data_structures에 대해 테스트를 실행합니다 |
표준 라이브러리는 기능을 검증하는 데 문서 테스트에 매우 크게 의존합니다. 하지만 필요에 따라 단위 테스트와 통합 테스트도 사용할 수 있습니다. 거의 모든 컴파일러 패키지에서는 문서 테스트가 비활성화되어 있습니다.
표준 라이브러리와 컴파일러의 모든 단위 테스트는 별도의 tests 파일에 배치됩니다(이는 tidy에서 강제합니다). 이렇게 하면 테스트 파일이 변경되어도 크레이트를 다시 컴파일할 필요가 없습니다. 예를 들면 다음과 같습니다.
#[cfg(test)]
mod tests;
이렇게 하지 않았다면 core 같은 부분을 작업할 때 표준 라이브러리 전체와 rustc 전체를 다시 컴파일해야 했을 것입니다.
./x test에는 이러한 패키지 테스트의 동작을 제어하는 몇 가지 CLI 옵션이 있습니다.
--doc— 패키지의 문서 테스트만 실행합니다.--all-targets— 문서 테스트를 제외한 모든 테스트를 실행합니다.--tests— 단위 테스트와 통합 테스트만 실행합니다.
Tidy
Tidy는 긴 줄을 거부하는 등 소스 코드 스타일과 포맷팅 규칙을 검증하는 데 사용하는 자체 도구입니다. 자세한 내용은 코딩 규칙 섹션이나 Tidy Readme를 참고하십시오.
예시:
./x test tidy
포맷팅
Rustfmt는 빌드 시스템에 통합되어 있어 컴파일러 전체에 일관된 스타일을 적용합니다. 포맷팅 검사는 위에서 언급한 Tidy 도구가 자동으로 실행합니다.
예시:
| 명령 | 설명 |
|---|---|
./x fmt --check | 포맷팅을 검사하고, 포맷팅이 필요하면 오류와 함께 종료합니다. |
./x fmt | 코드베이스 전체에 rustfmt를 실행합니다. |
./x test tidy --bless | 먼저 rustfmt를 실행해 코드베이스를 포맷팅한 다음 tidy 검사를 실행합니다. |
책 문서 테스트
출판되는 모든 책에는 각자의 테스트가 있으며, 이 테스트는 주로 Rust 코드 예제가 통과하는지 검증합니다. 내부적으로 이 테스트들은 사실상 Markdown 파일에 대해 rustdoc --test를 사용합니다. 책 경로를 ./x test에 전달하면 테스트를 실행할 수 있습니다.
예시:
./x test src/doc/book
문서 링크 검사기
모든 문서의 링크는 링크 검사 도구로 검증하며, 다음과 같이 실행할 수 있습니다.
./x test linkchecker
이 작업은 모든 문서를 빌드해야 하므로 시간이 다소 걸릴 수 있습니다.
distcheck
distcheck는 빌드 시스템이 만든 소스 배포 tarball이 정상적으로 압축 해제되고 빌드되며 모든 테스트를 실행하는지 검증합니다.
./x test distcheck
도구 테스트
Rust에 포함된 패키지들도 모든 테스트를 실행합니다. 여기에는 cargo, clippy, rustfmt, miri, bootstrap(Rust 빌드 시스템 자체를 테스트함) 등이 포함됩니다.
대부분의 도구는 src/tools 디렉터리에 있습니다. 도구의 테스트를 실행하려면 해당 경로를 ./x test에 전달하기만 하면 됩니다.
예:
./x test src/tools/cargo
일반적으로 이러한 도구의 테스트는 도구 디렉터리 안에서 cargo test를 실행하는 방식으로 이루어집니다.
지정한 테스트 집합만 실행하려면 명령에 --test-args FILTER_NAME을 덧붙이십시오.
예:
./x test src/tools/miri --test-args padding
CI에서는 일부 도구의 실패가 허용됩니다. 실패하면 해당 팀에 알림이 전송되며, 실패 내역은 toolstate 웹사이트에서 추적됩니다. 자세한 내용은 toolstate 문서에서 확인할 수 있습니다.
생태계 테스트
Rust는 회귀를 발견하고 언어의 발전에 관해 근거 있는 결정을 내리기 위해 실제 코드와의 통합을 테스트합니다. Crater를 비롯하여 여러 종류의 생태계 테스트가 있습니다. 자세한 내용은 생태계 테스트 장을 참고하십시오.
성능 테스트
컴파일러의 성능을 테스트하고 추적하는 데에는 별도의 인프라가 사용됩니다. 자세한 내용은 성능 테스트 장을 참고하십시오.
코드 생성 백엔드 테스트
코드 생성 백엔드 테스트를 참고하십시오.
기타 정보
테스트와 관련된 그 밖의 유용한 정보는 기타 정보에 있습니다.
더 읽을거리
다음 블로그 글도 흥미로울 수 있습니다:
- brson의 고전적인 글 “How Rust is tested”
테스트 실행하기
x를 사용하여 전체 테스트 모음을 실행할 수 있습니다. 하지만 전체 테스트 모음을 실행하면 시간이 매우 오래 걸리므로 로컬 개발 중에는 거의 필요하지 않다는 점에 유의하십시오. 로컬에서 개발할 때 테스트의 일부만 실행하는 방법은 뒤에 나오는 하위 절을 참고하십시오.
./x test만 실행하면 stage 1 컴파일러를 빌드한 다음 전체 테스트 스위트를 실행합니다. 여기에는 tests/뿐만 아니라 library/, compiler/, src/tools/ 패키지 테스트 등도 포함됩니다.
보통은 변경 사항을 검증할 것으로 예상되는 테스트 스위트의 일부(또는 그보다 더 작은 테스트 집합)만 실행하면 됩니다. PR CI는 테스트 모음의 일부를 실행하며, 병합 큐 CI는 테스트 모음 전체를 실행합니다.
./x test
테스트 결과는 캐시되며, 이전에 성공한 테스트는 테스트 중에 ignored 처리됩니다. 각 테스트의 stdout/stderr 내용과 타임스탬프 파일은 해당 <target-tuple>에 대한 build/<target-tuple>/test/ 아래에서 찾을 수 있습니다. 테스트를 강제로 다시 실행하려면(예: 테스트 러너가 변경 사항을 감지하지 못한 경우) --force-rerun CLI 옵션을 사용할 수 있습니다.
외부 의존성 요구 사항에 관한 참고 사항
일부 테스트 스위트는 외부 의존성이 필요할 수 있습니다. 특히 debuginfo 테스트가 그렇습니다. 일부 debuginfo 테스트에는 Python이 활성화된 gdb가 필요합니다. gdb 안에서
python명령을 사용하면 설치된 gdb가 Python을 지원하는지 테스트할 수 있습니다. 명령을 실행한 뒤 Python 코드(예:print("hi"))를 입력하고 엔터 키를 누른 다음CTRL+D를 누르면 코드가 실행됩니다. gdb를 소스에서 빌드하는 경우--with-python=<path-to-python-binary>옵션으로 구성해야 합니다.
테스트 스위트의 일부 실행하기
특정 PR 작업을 할 때는 보통 더 작은 테스트 집합을 실행하게 됩니다. 예를 들어 rustc를 수정한 뒤 전반적으로 올바르게 동작하는지 확인하는 데 쓸 수 있는 좋은 “스모크 테스트“는 ui 테스트 스위트(tests/ui)를 실행하는 것입니다:
./x test tests/ui
물론 테스트 스위트의 선택은 다소 임의적이며, 수행 중인 작업에 맞지 않을 수도 있습니다. 예를 들어 debuginfo 관련 작업을 하고 있다면 debuginfo 테스트 스위트를 사용하는 편이 더 나을 수 있습니다:
./x test tests/debuginfo
특정 테스트 스위트에서 테스트의 특정 하위 디렉터리만 테스트하면 되는 경우, 해당 디렉터리를 ./x test에 필터로 전달할 수 있습니다:
./x test tests/ui/const-generics
MSYS2 관련 참고 사항
MSYS2에서는 경로가 이상하게 처리되는 것으로 보이며,
./x test가tests/ui/const-generics와tests\ui\const-generics를 모두 인식하지 못합니다. 이 경우./x test ui --test-args="tests/ui/const-generics"와 같은 방식으로 우회할 수 있습니다.
마찬가지로 경로를 전달하여 단일 파일을 테스트할 수 있습니다:
./x test tests/ui/const-generics/const-test.rs
x는 아직 경로를 전달하여 단일 도구 테스트를 실행하는 기능을 지원하지 않습니다. 아래에서 설명하는 것처럼 --test-args 인자를 사용해야 합니다.
./x test src/tools/miri --test-args tests/fail/uninit/padding-enum.rs
tidy 스크립트만 실행하기
./x test tidy
표준 라이브러리 테스트 실행하기
./x test --stage 0 library/std
이 명령은 std에 대해서만 테스트를 실행한다는 점에 유의하십시오. core나 다른 크레이트를 테스트하려면 해당 크레이트를 명시적으로 지정해야 합니다.
표준 라이브러리에 대해 tidy 스크립트와 테스트를 실행합니다
./x test --stage 0 tidy library/std
stage 1 컴파일러를 사용하여 표준 라이브러리에 대한 테스트를 실행합니다
./x test --stage 1 library/std
실행할 테스트 스위트를 나열하면 전혀 변경하지 않은 구성 요소의 테스트까지 실행하지 않아도 됩니다.
bors는 전체 stage 2 빌드로만 테스트를 실행한다는 점에 유의하십시오. 따라서 테스트가 stage 1에서도 대개 잘 동작하기는 하지만 몇 가지 제약이 있습니다.
stage 2 컴파일러를 사용하여 모든 테스트를 실행합니다
./x test --stage 2
컴파일러/라이브러리에 대한 단위 테스트를 실행합니다
다음과 같이 특정 파일에 대해 단위 테스트를 실행하고 싶을 수도 있습니다:
./x test compiler/rustc_data_structures/src/thin_vec/tests.rs
하지만 안타깝게도 그렇게 할 수는 없습니다. 대신 다음을 실행하십시오:
./x test compiler/rustc_data_structures/ --test-args thin_vec
개별 테스트 실행하기
사람들이 흔히 하려는 또 다른 작업은 개별 테스트, 대개 자신이 고치려는 테스트를 실행하는 것입니다. 앞서 언급했듯이 이를 위해 전체 파일 경로를 전달할 수도 있고, --test-args 옵션과 함께 x를 실행할 수도 있습니다:
./x test tests/ui --test-args issue-1234
내부적으로 테스트 러너는 표준 Rust 테스트 러너(#[test]를 사용할 때와 같은 러너)를 호출하므로, 이 명령은 결국 이름에 “issue-1234“가 포함된 테스트만 걸러 내게 됩니다. 따라서 --test-args는 서로 관련된 여러 테스트를 한꺼번에 실행하기에 좋은 방법입니다.
테스트를 실행할 때 rustc에 인자 전달하기
RUSTFLAGS를 사용하지 않고 특정 컴파일러 인자로 일부 테스트를 실행하는 것이 유용할 때가 있습니다(예를 들어 불안정 기능을 개발하는 중에 -Z 플래그를 사용하는 경우).
이는 ./x test의 --compiletest-rustc-args 옵션으로 할 수 있으며, 이 옵션은 테스트를 빌드할 때 컴파일러에 추가 인자를 전달합니다.
참조 파일 편집 및 갱신하기
컴파일러의 출력을 의도적으로 변경했거나 새 테스트를 작성하는 중이라면 test 하위 명령에 --bless를 전달할 수 있습니다.
예를 들어 tests/ui의 일부 테스트가 실패한다면 다음 명령을 실행할 수 있습니다:
./x test tests/ui --bless
이 명령은 모든 test/ui 테스트의 .stderr, .stdout, .fixed 파일을 자동으로 조정합니다. 물론 --bless 플래그 없이 테스트를 실행할 때와 마찬가지로 --test-args your_test_name 플래그를 사용하여 특정 테스트만 대상으로 지정할 수도 있습니다.
테스트 실행 구성하기
테스트 실행에는 몇 가지 옵션이 있습니다:
bootstrap.toml에는rust.verbose-tests옵션이 있습니다.false이면 각 테스트마다 점 하나를 출력합니다(기본값).true이면 모든 테스트의 이름을 출력합니다. 이는 Rust 테스트 하네스의--quiet옵션에 해당합니다.- 환경 변수
RUST_TEST_THREADS에 테스트에 사용할 동시 스레드 수를 설정할 수 있습니다.
--pass $mode 전달하기
Pass UI 테스트에는 이제 check-pass, build-pass, run-pass의 세 가지 모드가 있습니다. --pass $mode를 전달하면, 테스트 파일에 //@ no-pass-override 지시문이 없는 한 이 테스트들은 주어진 $mode로 강제 실행됩니다. 예를 들어, tests/ui에 있는 모든 테스트를 check-pass로 실행할 수 있습니다:
./x test tests/ui --pass check
--pass $mode를 전달하면 테스트 시간을 줄일 수 있습니다. 각 모드에 대해서는 통과/실패 기대치 제어하기를 참조하십시오.
다른 “비교 모드“로 테스트 실행하기
UI 테스트는 컴파일러가 어떤 “모드“에 있는지에 따라 다른 출력을 낼 수 있습니다. 예를 들어, Polonius 모드를 사용할 때 테스트 foo.rs는 먼저 foo.polonius.stderr에서 기대 출력을 찾고, 찾지 못하면 일반적인 foo.stderr를 대신 사용합니다. 다음 명령은 UI 테스트 스위트를 Polonius 모드로 실행합니다:
./x test tests/ui --compare-mode=polonius
자세한 내용은 비교 모드를 참조하십시오.
테스트 수동으로 실행하기
때로는 테스트를 그냥 직접 실행하는 편이 더 쉽고 빠릅니다. 대부분의 테스트는 단순한 .rs 파일이므로, rustup 툴체인을 만든 후에 다음과 같이 할 수 있습니다:
rustc +stage1 tests/ui/issue-1234.rs
이 방법은 훨씬 빠르지만 항상 동작하지는 않습니다. 예를 들어, 일부 테스트에는 특정 컴파일러 플래그를 지정하거나 다른 크레이트에 의존하는 지시문이 포함되어 있으며, 이러한 옵션 없이는 동일하게 실행되지 않을 수 있습니다.
원격 머신에서 테스트 실행하기
테스트는 원격 머신에서 실행할 수도 있습니다(예: 다른 아키텍처용 빌드를 테스트하는 경우). 이는 빌드 머신에서 remote-test-client를 사용하여 원격 머신에서 실행 중인 remote-test-server로 테스트 프로그램을 보내는 방식으로 이루어집니다. remote-test-server는 테스트 프로그램을 실행하고 그 결과를 빌드 머신으로 다시 보냅니다. remote-test-server는 인증되지 않은 원격 코드 실행을 제공하므로 어디에서 사용하는지 주의하십시오.
이를 위해 먼저 원격 머신용 remote-test-server를 빌드하십시오(RISC-V를 예로 사용합니다):
./x build src/tools/remote-test-server --target riscv64gc-unknown-linux-gnu
바이너리는 ./build/host/stage2-tools/$TARGET_ARCH/release/remote-test-server에 생성됩니다. 이것을 원격 머신으로 복사하십시오.
원격 머신에서 --bind 0.0.0.0:12345 플래그(그리고 선택적으로 --verbose 플래그)와 함께 remote-test-server를 실행하십시오. 출력은 다음과 같아야 합니다:
$ ./remote-test-server --verbose --bind 0.0.0.0:12345
starting test server
listening on 0.0.0.0:12345!
서버를 0.0.0.0에 바인딩하면 여러분의 머신에 접근할 수 있는 모든 호스트가 여러분의 머신에서 임의의 코드를 실행할 수 있게 된다는 점에 유의하십시오. 포트 12345에 대한 외부 접근을 차단(블록)하도록 방화벽을 설정하거나, 바인딩할 때 더 제한적인 IP 주소를 사용할 것을 강력히 권장합니다.
remote-test-server에 연결하여 ping\n을 보내면 서버가 동작하는지 테스트할 수 있습니다. pong으로 응답해야 합니다:
$ nc $REMOTE_IP 12345
ping
pong
원격 러너를 사용하여 테스트를 실행하려면 TEST_DEVICE_ADDR 환경 변수를 설정한 다음 평소처럼 x를 사용하십시오. 예를 들어, IP 주소가 1.2.3.4인 RISC-V 머신에서 ui 테스트를 실행하려면 다음을 사용하십시오
export TEST_DEVICE_ADDR="1.2.3.4:12345"
./x test tests/ui --target riscv64gc-unknown-linux-gnu
remote-test-server를 verbose 플래그와 함께 실행했다면, 테스트 머신의 출력은 대략 다음과 같을 수 있습니다
[...]
run "/tmp/work/test1007/a"
run "/tmp/work/test1008/a"
run "/tmp/work/test1009/a"
run "/tmp/work/test1010/a"
run "/tmp/work/test1011/a"
run "/tmp/work/test1012/a"
run "/tmp/work/test1013/a"
run "/tmp/work/test1014/a"
run "/tmp/work/test1015/a"
run "/tmp/work/test1016/a"
run "/tmp/work/test1017/a"
run "/tmp/work/test1018/a"
[...]
테스트는 원격 머신이 아니라 x를 실행하는 머신에서 빌드됩니다. 예기치 않게 빌드에 실패하는 테스트(또는 잘못된 빌드 출력을 내는 ui 테스트)는 원격 머신에서 한 번도 실행되지 않은 채 실패할 수 있습니다.
x 명령이 remote-test-server에 연결하지 못하는 경우를 대비하여 기본 타임아웃이 30분으로 설정되어 있습니다. 이 타임아웃은 TEST_DEVICE_CONNECT_TIMEOUT_SECONDS 환경 변수로 변경할 수 있습니다.
에뮬레이터에서 테스트하기
쉽게 구할 수 없는 아키텍처의 경우, 일부 플랫폼은 에뮬레이터를 통해 테스트합니다. 표준 라이브러리가 잘 지원되고 호스트 운영 체제가 TCP/IP 네트워킹을 지원하는 아키텍처라면, 위에서 설명한 원격 머신 테스트 방법을 참고하십시오(이 경우 원격 머신은 에뮬레이션된 머신입니다).
에뮬레이터 안에서 테스트 실행을 조율하는 도구 모음도 있습니다. arm-android나 arm-unknown-linux-gnueabihf 같은 플랫폼은 GitHub Actions에서 에뮬레이션 환경으로 테스트를 자동 실행하도록 구성되어 있습니다. 아래에서는 대상의 테스트가 에뮬레이션 환경에서 어떻게 실행되는지 살펴봅니다.
armhf-gnu용 Docker 이미지에는 ARM CPU 아키텍처를 에뮬레이션하는 QEMU가 포함되어 있습니다. Rust 트리에는 테스트 프로그램과 라이브러리를 에뮬레이터로 보내고, 에뮬레이터 안에서 테스트를 실행하고, 그 결과를 읽어 오는 프로그램인 remote-test-client와 remote-test-server 도구가 포함되어 있습니다. Docker 이미지는 remote-test-server를 실행하도록 구성되어 있으며, 빌드 도구는 remote-test-client로 서버와 통신하여 테스트 실행을 조율합니다(src/bootstrap/src/core/build_steps/test.rs 참고).
iOS/tvOS/watchOS/visionOS 시뮬레이터에서 실행할 때도 마찬가지로 시뮬레이터를 “원격” 머신으로 취급할 수 있습니다. 흥미로운 점은 시뮬레이터 인스턴스와 호스트 macOS가 네트워크를 공유하므로 로컬 루프백 주소 127.0.0.1을 사용할 수 있다는 것입니다. 다음과 같이 하면 동작할 것입니다:
# Build the test server for the iOS simulator:
./x build src/tools/remote-test-server --target aarch64-apple-ios-sim
# If you already have a simulator instance open, copy the device UUID from:
xcrun simctl list devices booted
UDID=01234567-89AB-CDEF-0123-456789ABCDEF
# Alternatively, create and boot a new simulator instance:
xcrun simctl list runtimes
xcrun simctl list devicetypes
UDID=$(xcrun simctl create $CHOSEN_DEVICE_TYPE $CHOSEN_RUNTIME)
xcrun simctl boot $UDID
# See https://nshipster.com/simctl/ for details.
# Spawn the runner on port 12345:
xcrun simctl spawn $UDID ./build/host/stage2-tools/aarch64-apple-ios-sim/release/remote-test-server -v --bind 127.0.0.1:12345
# In a new terminal, run tests via the runner:
export TEST_DEVICE_ADDR="127.0.0.1:12345"
./x test --host='' --target aarch64-apple-ios-sim --skip tests/debuginfo
# FIXME(madsmtm): Allow debuginfo tests to work (maybe needs `.dSYM` folder to be copied to the target?).
wasi(wasm32-wasip1)에서 테스트하기
일부 테스트는 wasm 대상 전용입니다. 이 테스트를 실행하려면 x test에 --target wasm32-wasip1을 전달해야 합니다. 또한 wasi sdk가 필요합니다. wasi sdk 저장소의 설치 안내를 따라 컴퓨터에 sysroot를 마련하십시오. wasm32-wasip1 대상 지원 페이지에 sdk가 빌드할 수 있어야 하는 최소 버전이 명시되어 있습니다. cmake 명령 몇 개를 실행하면 시간이 꽤 걸리고, 매우 걱정스러워 보이는 c++ 경고가 잔뜩 출력됩니다… 그런 다음 bootstrap.toml에서 다음과 같이 sysroot를 지정하십시오:
[target.wasm32-wasip1]
wasi-root = "<wasi-sdk location>/build/sysroot/install/share/wasi-sysroot"
저는 rust 폴더 옆에 git clone을 했기 때문에 경로가 ../wasi-sdk/build/....였습니다. 이제 다른 설정 없이 테스트가 바로 실행될 것입니다.
Docker로 테스트하기
src/ci/docker 디렉터리에는 GitHub Actions에서 실행되는 Linux 기반 작업을 위한 Docker 이미지 정의가 들어 있습니다(Linux가 아닌 작업은 Docker 밖에서 실행됩니다). 이러한 작업은 로컬 개발 머신에서 실행할 수 있으며, 로컬 시스템과 다른 환경을 테스트할 때 유용합니다. Linux, Windows 또는 macOS 시스템에 Docker를 설치해야 합니다(Windows와 macOS는 가상 머신으로 Linux 환경을 에뮬레이션하므로 일반적으로 Linux가 훨씬 빠릅니다).
CI에서 실행되는 작업은 여러 bash 스크립트로 구성되어 있어서 로컬에서 그 동작을 재현하기가 항상 간단하지는 않습니다. CI 작업을 로컬에서 가장 간단하게 실행하려면 CI에서 일어나는 일을 최대한 비슷하게 재현하는 도우미 도구 citool을 사용할 수 있습니다:
cargo run --manifest-path src/ci/citool/Cargo.toml run-local <job-name>
# For example:
cargo run --manifest-path src/ci/citool/Cargo.toml run-local dist-x86_64-linux-alt
위 스크립트가 제대로 동작하지 않거나, Docker 이미지 실행을 더 세밀하게 제어하고 싶거나, Docker 작업이 실행될 때 정확히 무슨 일이 일어나는지 이해하고 싶다면 아래 내용을 계속 읽으십시오.
run.sh 스크립트
src/ci/docker/run.sh 스크립트는 특정 Docker 이미지를 빌드하고 실행한 뒤, 이미지 안에서 Rust를 빌드하고, 테스트를 실행하거나 배포용 아카이브 묶음을 준비하는 데 사용됩니다. 이 스크립트는 로컬 Rust 소스 트리를 읽기 전용 모드로, obj 디렉터리를 읽기-쓰기 모드로 마운트합니다. 모든 컴파일러 산출물은 obj 디렉터리에 저장됩니다. 셸은 obj디렉터리에서 시작합니다. 그곳에서 ../src/ci/run.sh를 실행하며, 이 스크립트는 Docker 이미지에 정의된 대로 빌드를 시작합니다.
src/ci/docker/run.sh <image-name>을 직접 실행할 수 있습니다. run.sh 스크립트와 관련된 몇 가지 중요한 참고 사항은 다음과 같습니다:
- CI에서 실행될 때 이 스크립트는 모든 서브모듈이 체크아웃되어 있다고 가정합니다. 작업이 접근하는 서브모듈 중 사용할 수 없는 것이 있으면 빌드에서 오류가 발생합니다. 따라서 필요한 모든 서브모듈이 로컬에 체크아웃되어 있는지 확인해야 합니다. git으로 직접 체크아웃하거나,
bootstrap.toml에build.submodules = true를 설정하고x build같은 명령을 실행하여 bootstrap이 가장 중요한 서브모듈을 내려받도록 할 수 있습니다. 다만 이것만으로는 실행하려는 CI 작업에 충분하지 않을 수도 있다는 점에 유의하십시오. <image-name>은src/ci/docker/host-*디렉터리 중 하나에 있는 단일 디렉터리에 대응합니다. 이미지 이름이 반드시 작업 이름과 일치하지는 않는다는 점에 유의하십시오. 일부 작업은 같은 이미지를 실행하되 환경 변수나 Docker 빌드 인자를 다르게 사용하기 때문입니다. 이는 CI 작업을 로컬에서 실행하기 어렵게 만드는 복잡성의 일부입니다.- “dist” 작업(
dist-로 시작하는 작업)을 실행하는 경우DEPLOY=1환경 변수를 설정해야 합니다. - “alternative dist” 작업(
dist-로 시작하고-alt로 끝나는 작업)을 실행하는 경우DEPLOY_ALT=1환경 변수를 설정해야 합니다. - 일부 std 테스트는 IPv6 지원이 필요합니다. Linux의 Docker는 기본적으로 이 기능이 비활성화되어 있는 것으로 보입니다. 컨테이너를 만들기 전에
enable-docker-ipv6.sh에 있는 명령을 실행하여 IPv6를 활성화하십시오. 이 작업은 한 번만 하면 됩니다.
대화형 모드
때로는 특정 Docker 이미지를 빌드한 뒤 그 안에서 원하는 명령을 실행하여 해당 시스템이 어떻게 동작하는지 실험해 보는 것이 유용할 수 있습니다. 이를 위해 src/ci/docker/run.sh --dev <image-name>을 사용하여 컨테이너 안에서 bash 셸을 시작하는 대화형 모드를 사용할 수 있습니다.
Docker 컨테이너 안에서는 개별 명령을 실행하여 특정 작업을 수행할 수 있습니다. 예를 들어 ../x test tests/ui를 실행하면 UI 테스트만 실행할 수 있습니다.
대화형 모드 사용에 관한 몇 가지 추가 참고 사항은 다음과 같습니다.
- 셸을 종료하면 컨테이너는 자동으로 삭제되지만, 빌드 산출물은
obj디렉터리에 남습니다. 서로 다른 Docker 이미지를 오가며 사용하는 경우,obj디렉터리에 저장된 이전 환경의 산출물 때문에 빌드 시스템이 혼동할 수 있습니다. 컨테이너 내부에서 빌드하기 전에obj디렉터리의 일부 또는 전체를 삭제해야 할 때도 있습니다. - 컨테이너는 최소한의 패키지만 포함된 기본 구성입니다.
apt install less vim과 같이 필요한 도구를 설치하는 것이 좋습니다. - 컨테이너에서 여러 개의 셸을 열 수 있습니다. 먼저 셸 프롬프트에 표시되는 컨테이너 이름(짧은 해시)이 필요합니다. 또는 컨테이너 외부에서
docker container ls를 실행하여 사용 가능한 컨테이너 목록을 확인할 수 있습니다. 컨테이너 이름을 확인했으면docker exec -it <CONTAINER> /bin/bash를 실행하십시오. 여기서<CONTAINER>는4ba195e95cef와 같은 컨테이너 이름입니다.
CI를 이용한 테스트
CI 시스템의 주된 목표는 테스트 스위트를 통과시켜 rust-lang/rust의 main 브랜치가 항상 유효한 상태를 유지하도록 보장하는 것입니다.
큰 틀에서 보면, rust-lang/rust에 풀 리퀘스트를 열었을 때 다음과 같은 일이 일어납니다.
- PR에 푸시할 때마다 테스트와 검사의 작은 일부가 실행됩니다. 이를 통해 흔한 오류를 잡아낼 수 있습니다.
- PR이 승인되면 bors 봇이 PR을 병합 대기열에 넣습니다.
- PR이 대기열의 맨 앞에 도달하면 bors가 병합 커밋을 만들고 그 커밋에 대해 전체 테스트 스위트를 실행합니다. 병합 커밋은 특정 PR 하나만 포함할 수도 있고, CI 비용과 병합 지연을 줄이기 위해 여러 PR을 하나로 묶은 “롤업(rollup)”일 수도 있습니다.
- 전체 테스트 스위트가 끝나면 두 가지 중 하나가 일어납니다. 개발자가 해결해야 하는 오류로 CI가 실패하거나, CI가 성공하여 병합 커밋이
main브랜치에 푸시됩니다.
CI에서 실행되는 내용을 수정하려면 CI 작업 수정하기를 참고하십시오.
CI 워크플로
CI는 주로 GitHub Actions에서 실행되며, .github/workflows/ci.yml에 정의된 단일 워크플로를 사용합니다. 이 워크플로에는 실행하는 모든 CI 작업에 공통으로 적용되는 여러 단계가 들어 있습니다. 해당 브랜치나 PR에 커밋이 푸시되면 워크플로는 src/ci/citool 크레이트를 실행하며, 이 크레이트는 실행해야 할 구체적인 CI 작업을 동적으로 생성합니다. 이 스크립트는 모든 CI 작업의 선언적 구성이 담긴 jobs.yml 파일을 입력으로 사용합니다.
거의 모든 빌드 단계는 별도의 스크립트를 셸로 호출합니다. 덕분에 CI는 플랫폼에 상당히 독립적입니다(즉, GitHub Actions에 과도하게 의존하지 않습니다). GitHub Actions는 CI 프로세스를 부트스트래핑하고 프로세스를 구동하는 스크립트를 조율하는 용도로만 사용됩니다.
본질적으로 모든 CI 작업은 다양한 운영 체제, 타깃, 플랫폼에 걸쳐 서로 다른 구성으로 ./x test, ./x dist 또는 그 밖의 명령을 실행합니다. 실행되는 작업은 크게 dist 작업과 비dist 작업의 두 가지 범주로 나뉩니다.
- Dist 작업은 rustup을 통해 배포하는 모든 도구를 포함하여 특정 플랫폼용 컴파일러의 전체 릴리스를 빌드합니다. 그런 다음 이 빌드는
rust-lang-ci2S3 버킷에 업로드되며, rustup-toolchain-install-master 툴체인 설치 도구로 로컬에 설치할 수 있습니다. 동일한 빌드는 실제 릴리스에도 사용됩니다. 우리의 릴리스 프로세스는 기본적으로 이 산출물을rust-lang-ci2에서 프로덕션 엔드포인트로 복사하고 서명하는 것으로 이루어집니다. - 비dist 작업은 해당 플랫폼에서 전체 테스트 스위트와 rustup을 통해 배포하는 모든 도구의 테스트 스위트를 실행합니다. 테스트하는 범위는 플랫폼에 따라 다르며(예를 들어 일부 테스트는 Tier 1 플랫폼에서만 실행됩니다), CI 자원 낭비를 막기 위해 비교적 빠른 일부 플랫폼은 같은 빌더에 묶어서 실행합니다.
입력 이벤트(보통 브랜치에 대한 푸시)에 따라 세 가지 종류의 빌드(작업 집합) 중 하나를 실행합니다.
- PR 빌드
- 자동(Auto) 빌드
- Try 빌드
풀 리퀘스트 빌드
풀 리퀘스트에 푸시할 때마다 pr 작업 묶음이 실행됩니다. 현재는 test-x86_64-gnu-llvm-X, test-x86_64-gnu-tools, test-pr-check-1, test-pr-check-2, test-tidy 작업이 실행되며, 모두 Linux에서 실행됩니다. 이 작업들은 흔한 문제를 잡아낼 수 있도록 비교적 짧고(약 40분) 가벼운 테스트 스위트를 실행합니다. 좀 더 구체적으로 말하면, 이 작업은 일련의 린트를 실행하고, Windows mingw를 대상으로 하는 교차 컴파일 check 빌드를 (산출물을 생성하지 않고) 수행해 보며, LLVM의 시스템 버전을 사용하여 컴파일러를 테스트합니다. 아쉽게도 모든 PR의 커밋마다 전체 테스트 스위트를 실행하려면 너무 많은 자원이 필요합니다.
문서 주석에 관한 참고 사항
2024년 10월 기준 PR CI는 기본적으로
./x doc xxx를 실행하지 않는다는 점에 유의하십시오. 따라서./x doc xxx를 실패하게 만드는 깨진 문서 내부 링크(intradoc link)가 있다면, 그 실패는 전체 병합 대기열 CI 파이프라인의 매우 늦은 단계에서야 드러납니다.그러므로 문서 주석을 변경했다면 로컬에서
./x doc xxx를 실행하여 이러한 문제를 일찍 잡아내는 것이 좋습니다.
PR 작업은 jobs.yml의 pr 섹션에 정의되어 있습니다. 그 결과는 PR 페이지 하단의 “CI checks” 섹션에서 바로 확인할 수 있습니다.
Auto 빌드
커밋을 main 브랜치에 병합하려면 먼저 전체 테스트 스위트를 통과해야 합니다. 이를 auto 빌드라고 합니다. 이 빌드는 여러 운영 체제와 타깃에 걸쳐 다양한 테스트를 수행하는 수십 개의 CI 작업을 실행합니다. 전체 테스트 스위트는 상당히 느려서, 모든 auto CI 작업이 끝나기까지 몇 시간이 걸릴 수 있습니다.
대부분의 플랫폼은 빌드 단계만 실행하고, 일부는 제한된 테스트만 실행하며, 전체 테스트 스위트를 실행하는 플랫폼은 일부에 불과합니다(Rust의 플랫폼 티어를 참고하십시오).
Auto 작업은 jobs.yml의 auto 섹션에 정의되어 있습니다. 이 작업들은 rust-lang/rust 저장소의 automation/bors/auto 브랜치에서 실행되며, 최종 결과는 bors가 해당 PR에 다는 댓글로 보고됩니다. 실시간 결과는 GitHub Actions 워크플로 페이지에서 볼 수 있습니다.
어느 시점에서든 실행되는 auto 빌드는 최대 하나뿐입니다. 자세한 내용은 bors로 PR을 순차적으로 병합하기를 참고하십시오.
일반적으로 auto 작업이 실패하면 전체 CI 워크플로가 즉시 종료됩니다. 하지만 “non-blocking”, 즉 선택적인 auto 작업을 만들어 두고, 해당 작업이 병합을 막도록 하기 전에 한동안 CI에서 테스트해 보는 것이 유용할 수 있습니다. 이는 해당 작업이 불안정(flaky)할 수 있는 경우에 유용합니다.
이렇게 하려면 해당 작업 이름 앞에 optional-를 붙이고, jobs.yml에서 그 작업에 continue_on_error: true를 설정하십시오.
Try 빌드
때로는 특정 PR을 병합하려 하지 않고도 해당 PR에 대해 CI에서 테스트 스위트의 일부를 실행하거나, 그 PR로부터 컴파일러 산출물 일체를 빌드하고 싶을 때가 있습니다. 이를 “try 빌드“라고 합니다. Try 빌드는 적절한 권한을 가진 사용자가 @bors try 명령이 담긴 PR 댓글을 게시하면 시작됩니다.
Try 빌드의 사용 사례는 다음과 같이 여러 가지가 있습니다.
- 저희의 rustc-perf 벤치마크 모음을 사용하여 일련의 성능 벤치마크를 실행합니다. 이를 위해서는 동작하는 컴파일러 빌드가 필요하며, 이 빌드는 Linux에서 최적화된 버전의 컴파일러를 빌드하는 dist-x86_64-linux CI 작업을 실행하는 try 빌드로 생성할 수 있습니다(현재 이 작업은 try 빌드를 시작하면 기본으로 실행됩니다). try 빌드를 생성하고 이를 성능 벤치마크에 예약하려면
@bors try @rust-timer queue명령 조합을 사용할 수 있습니다. - Crater 실행을 사용하여 PR이 Rust 생태계 전반에 미치는 영향을 확인합니다. 이 경우에도 동작하는 컴파일러 빌드가 필요하며, 이는 dist-x86_64-linux CI 작업으로 만들 수 있습니다.
- PR에서 특정 CI 작업(예: Windows 테스트)을 실행하여, 해당 작업이 실행하는 테스트 스위트를 통과하는지 빠르게 테스트하십시오.
기본적으로 @bors try로 댓글을 보내면 jobs.yml의 try 섹션에 정의된 작업이 실행됩니다. 저희는 이 모드를 “빠른 try 빌드“라고 부릅니다. 이러한 try 빌드는 어떤 테스트도 실행하지 않으며, 컴파일 경고를 허용합니다. 완전히 올바르게 동작하지 않을 수도 있더라도, Crater 실행이나 성능 벤치마크를 위해 최적화된 툴체인을 최대한 빨리 얻고 싶을 때 유용합니다.
빠른 try 빌드에서 실행되는 CI 작업에는 기본 try 작업의 전체 빌드와 구별하기 위해 특별한 접미사(-quick)가 붙습니다. 대신 전체 빌드를 수행하려면 작업 패턴(아래에서 설명합니다)에 해당 작업 이름을 지정하십시오.
try 빌드에서 사용자 지정 CI 작업을 실행하고, 그 작업이 모든 테스트를 통과하며 컴파일 경고를 발생시키지 않는지 확인하려면 작업 패턴을 지정하여 실행할 CI 작업을 선택할 수 있으며, 작업 패턴은 다음 두 가지 방법 중 하나로 사용할 수 있습니다:
- PR 설명에
try-job: <job pattern>지시문 집합을 추가한(아래에서 설명합니다) 다음 간단히@bors try를 실행하면 됩니다. CI는 이 지시문을 읽고 지정한 작업을 실행합니다. 이 방법은 PR을 점진적으로 수정한 후 동일한 try 작업 집합을 여러 번 다시 실행하려는 경우에 유용합니다. - try 명령의
jobs매개변수를 사용하여 작업 패턴을 지정할 수 있습니다:@bors try jobs=<job pattern>. 이 방법은 특정 작업으로 일회성 try 빌드를 수행할 때 유용합니다.jobs매개변수가 PR 설명 지시문보다 우선순위가 높다는 점에 유의하십시오.- 예를 들어
@bors try jobs=job1,job2,job3와 같이 여러 패턴을 지정할 수도 있습니다.
- 예를 들어
각 작업 패턴은 작업의 정확한 이름이거나 여러 작업과 일치하는 glob 패턴일 수 있습니다(예: *msvc* 또는 *-alt). 단일 try 빌드에서 최대 20개의 작업을 시작할 수 있습니다. PR 설명에서 glob 패턴을 사용할 때, 패턴에 별표 등이 포함되어 있으면 GitHub가 패턴을 Markdown으로 렌더링하지 않도록 선택적으로 백틱(`)으로 감쌀 수 있습니다. 이 이스케이프는 @bors jobs= 매개변수를 사용할 때는 동작하지 않는다는 점에 유의하십시오.
작업 패턴은 jobs.yml의 auto 또는 optional 섹션에 정의된 하나 이상의 작업과 일치해야 합니다:
auto작업은 커밋이main브랜치에 병합되기 전에 실행됩니다.optional작업은 try 빌드를 통해 명시적으로 요청된 경우에만 실행됩니다. 이 작업은 일반적으로 tier 2 및 tier 3 타깃에 사용됩니다.
try 빌드를 하는 이유 중 하나는 앞서 설명한 대로 @rust-timer queue를 사용하여 perf 실행을 하기 위해서입니다. 그러면 이 성능 빌드는 main의 어떤 커밋과 비교됩니다. @bors try parent=<sha>를 사용하면 try 빌드와 그에 이어지는 perf 실행의 기준을 main의 특정 커밋으로 지정할 수 있으며, 이를 통해 perf 비교를 최대한 공정하게 할 수 있습니다.
try-jobPR 설명 지시문 사용하기
실행하려는 try 작업 집합을 정하십시오. CI 작업의 이름은
jobs.yml에서 확인할 수 있습니다.PR 설명을 수정하여 패턴 집합을 포함하십시오(보통 PR 설명의 끝에 둡니다). 예를 들면 다음과 같습니다.
This PR fixes #123456. try-job: x86_64-msvc try-job: test-various try-job: `*-alt`각
try-job패턴은 한 줄에 하나씩 작성해야 합니다.
@bors try로 지정한 try 작업을 실행하십시오. 앞서 언급했듯이, 이를 위해서는 사용자가 (1)try권한을 가지고 있거나 (2)try권한을 가진 사람으로부터@bors delegate try를 통해try권한을 위임받아야 합니다.이 방법은 보통
jobs.yml을 직접 수정하는 것보다 쉽다는 점에 유의하십시오. 다만 이 방법으로는 실행되는 테스트 집합을 조정할 수 없으므로 유연성이 떨어질 수 있습니다.
try 빌드는 rust-lang/rust 저장소의 automation/bors/try 브랜치에서 실행되며 그 결과는 GitHub Actions 워크플로 페이지에서 확인할 수 있습니다. 다만 보통은 해당 PR에 bors가 남기는 댓글을 통해 결과를 알림받게 됩니다.
서로 다른 PR에서 여러 try 빌드가 동시에 실행될 수 있지만, 하나의 PR에서는 한 번에 최대 하나의 try 빌드만 실행될 수 있습니다.
CI 작업 수정하기
CI에서 실행되는 내용을 수정하려면 jobs.yml 파일의 pr, auto 또는 try 섹션을 수정하기만 하면 됩니다.
로컬에서 테스트하기 어려운 특정 플랫폼이나 구성을 테스트하기 위해 실행되는 내용을 일시적으로 수정할 수도 있습니다(예를 들어 Windows 빌드가 실패했지만 Windows 컴퓨터를 사용할 수 없는 경우). 이런 상황에서는 주저하지 말고 CI 자원을 사용하십시오.
임의의 CI 작업을 실행하는 방법은 두 가지입니다.
- try 빌드 기능을 사용하고, try 빌드에서 실행하려는 CI 작업을 PR 설명에 지정하십시오.
jobs.yml의pr섹션을 수정하여 PR에 푸시할 때마다 실행할 CI 작업을 지정하십시오. 이 방법은 try 빌드를 반복해서 시작하는 것보다 빠를 수 있습니다.
PR에 푸시할 때마다 실행되는 작업을 수정하려면 auto 섹션의 작업 정의 중 하나를 pr 섹션으로 복사하기만 하면 됩니다. 예를 들어 x86_64-msvc 작업은 64비트 MSVC 테스트를 실행하는 역할을 합니다. 다음과 같이 이 작업을 pr 섹션으로 복사하면 PR에 커밋이 푸시된 후 해당 작업이 실행됩니다.
pr:
...
- image: test-x86_64-gnu-tools
<<: *job-linux-16c
# this item was copied from the `auto` section
# vvvvvvvvvvvvvvvvvv
- image: test-x86_64-msvc
env:
RUST_CONFIGURE_ARGS: --build=x86_64-pc-windows-msvc --enable-profiler
SCRIPT: make ci-msvc
<<: *job-windows-8c
그런 다음 파일을 커밋하고 GitHub의 PR 브랜치에 푸시하면 됩니다. 그러면 GitHub Actions가 PR에 푸시할 때마다 이 CI 작업을 실행합니다.
실험을 마친 후, jobs.yml에 가한 변경이 일시적인 것이었다면 잊지 말고 모두 되돌리십시오!
try 작업을 실행하는 동안에는 PR 제목 앞에 [WIP]를 붙이고, 테스트 목적으로 CI 작업을 수정하는 커밋에는 [DO NOT MERGE]를 붙이는 것이 좋은 관행입니다.
CI는 얼마든지 사용해도 되지만, 동시 실행 수가 제한된 공유 자원이라는 점을 염두에 두십시오. 한 번에 너무 많은 작업을 활성화하지 마십시오. 대부분의 경우 한두 개면 충분합니다.
bors로 PR을 순차적으로 병합하기
CI 서비스는 보통 브랜치의 마지막 커밋을 main의 마지막 커밋과 병합한 상태로 테스트합니다. 이는 기능이 단독으로 동작하는지 확인하기에는 좋지만, 병합된 후에도 코드가 동작한다는 보장은 제공하지 않습니다. 이러한 문제는 보통 빌드가 실행된 후에 호환되지 않는 다른 PR이 병합될 때 발생합니다.
main 브랜치가 항상 동작하도록 보장하기 위해 저희는 수동 병합을 금지합니다. 대신 모든 PR은 저희 봇인 bors를 통해 승인받아야 합니다. 승인된 모든 PR은 (우선순위와 생성 날짜 순으로 정렬된) 병합 대기열에 들어가며, 한 번에 하나씩 자동으로 테스트됩니다. 모든 빌더가 통과하면 PR이 병합되고, 그렇지 않으면 실패가 기록되며 해당 PR은 다시 승인받아야 합니다.
Bors는 CI 서비스와 직접 상호작용하지 않습니다. 대신 테스트하려는 병합 커밋을 CI 검사를 실행하도록 구성된 특정 브랜치(automation/bors/auto나 automation/bors/try 등)에 푸시하는 방식으로 동작합니다. 그런 다음 Bors는 커밋 상태(Commit Status)나 검사 실행(Check Run)을 수신하여 빌드 결과를 감지합니다. 병합 커밋은 최신 main을 기반으로 하며 한 번에 하나만 테스트할 수 있으므로, 결과가 통과하면 main은 해당 병합 커밋으로 빨리 감기(fast-forward)됩니다.
안타깝게도 한 번에 하나의 PR만 테스트하는 방식에 긴 CI 시간(전체 실행에 약 2시간)까지 더해져, 하루에 많은 PR을 병합할 수 없으며 한 번의 실패가 처리량에 큰 영향을 미칩니다. 하루에 병합할 수 있는 PR의 최대 개수는 약 10개 정도입니다.
CI 실행 시간이 길고 대규모 빌더 풀이 필요한 것은 주로 dist- 빌더에서 전체 릴리스 산출물을 빌드하기 때문입니다. 그럼에도 그만한 가치가 있는 이유는 이 릴리스 산출물이 다음과 같기 때문입니다.
- 나중에라도 성능 테스트를 할 수 있게 합니다.
- 나중에 버그가 발견되었을 때 이분 탐색(bisection)을 할 수 있게 합니다.
- 항상 릴리스하고 있으면 문제를 조기에 발견할 수 있으므로 릴리스 품질을 보장합니다.
롤업
어떤 PR은 전체 테스트 스위트를 실행할 필요가 없습니다. 오타 수정이나 README 개선 같은 사소한 변경은 빌드를 깨뜨릴 리가 없으며, 이런 변경 하나하나를 2시간 넘게 테스트하는 것은 낭비입니다. 이 문제를 해결하기 위해 저희는 “롤업“을 정기적으로 만듭니다. 롤업은 대기 중인 여러 사소한 PR을 병합하여 함께 테스트할 수 있도록 하는 PR입니다. 롤업은 팀 구성원이 병합 대기열의 “create a rollup” 버튼을 사용하여 수동으로 생성합니다. 팀 구성원은 자신의 판단에 따라 PR이 위험한지 여부를 결정합니다.
Docker
macOS와 Windows의 작업을 제외한 모든 CI 작업은 해당 플랫폼 전용 Docker 컨테이너 안에서 실행됩니다. 이 방식에는 저희에게 다음과 같은 많은 장점이 있습니다.
-
기반 이미지가 바뀌어도 빌드 환경이 일관되게 유지됩니다(trusty 이미지에서 xenial로 전환할 때도 전혀 어려움이 없었습니다).
-
최대한의 바이너리 호환성을 보장하기 위해 아주 오래된 빌드 환경을 사용할 수 있습니다. 예를 들어 Linux 빌더에서는 오래된 CentOS 릴리스를 사용합니다.
-
Docker 이미지 캐싱 덕분에 (QEMU나 Android 에뮬레이터 같은) 도구를 매번 다시 설치하지 않아도 됩니다.
-
사용자는 다음 명령을 실행하기만 하면 로컬에서 같은 환경으로 같은 테스트를 실행할 수 있습니다.
cargo run --manifest-path src/ci/citool/Cargo.toml run-local <job-name>이는 실패를 디버깅할 때 유용합니다. 라이선스 및 기타 제약으로 인해 로컬에서는 Linux Docker 이미지만 사용할 수 있다는 점에 유의하십시오.
dist- 접두사가 붙은 Docker 이미지는 산출물을 빌드하는 데 사용되며, 해당 접두사가 없는 이미지는 테스트와 검사를 실행합니다.
저희는 CI에서 덜 일반적인 아키텍처(주로 Tier 2 및 Tier 3 플랫폼)에 대한 테스트도 실행합니다. 이러한 플랫폼은 x86이 아니므로, 모든 것을 QEMU 안에서 실행하거나, 해당 플랫폼의 테스트를 실행하지 않으려는 경우에는 교차 컴파일만 합니다.
이 빌더들은 GitHub가 저희를 위해 구성하고 유지 관리하는 특별한 빌더 풀에서 실행됩니다.
캐싱
우리의 CI 워크플로는 여러 가지 캐싱 메커니즘을 사용하며, 주로 다음 두 가지 용도로 사용합니다:
Docker 이미지 캐싱
대부분의 Linux 기반 빌더를 실행하는 데 사용하는 Docker 이미지는 완전히 빌드하는 데 오랜 시간이 걸립니다. 빌드 속도를 높이기 위해 Docker 레지스트리 캐싱을 사용하여 이미지를 캐시하며, 중간 산출물은 ghcr.io에 저장됩니다. 또한 빌드된 Docker 이미지를 ghcr에 푸시하여 다른 도구(rustup)나 로컬에서 Docker 빌드를 실행하는 개발자가 이미지를 재사용할 수 있도록 합니다(개발자의 빌드 속도를 높이기 위해서입니다).
우리는 서로 갈라진 여러 브랜치(main, beta, stable)를 테스트하므로 이미지에 단일 캐시를 사용할 수 없습니다. 그렇게 하면 한 브랜치의 빌드가 다른 브랜치의 캐시를 덮어쓰게 됩니다. 대신 이미지를 서로 다른 태그로 저장하며, 모든 Dockerfile과 관련 스크립트의 내용으로 만든 사용자 정의 해시로 각 이미지를 식별합니다.
CI는 해시 키를 계산하여, 다음 중 하나가 변경되면 Docker 이미지의 캐시가 무효화되도록 합니다:
- Dockerfile
- Dockerfile에서 Docker 이미지로 복사되는 파일
- GitHub 러너의 아키텍처(x86 또는 ARM)
Sccache를 이용한 LLVM 캐싱
우리는 여러 CI 작업에서 C/C++ 관련 코드를 빌드하며, 중간 LLVM 산출물을 캐시하기 위해 Sccache를 사용합니다. Sccache는 Mozilla가 개발한 분산 ccache로, 객체 스토리지 버킷을 저장소 백엔드로 사용할 수 있습니다.
Sccache를 사용하면 해시 키를 직접 계산할 필요가 없습니다. Sccache는 소스 코드, 컴파일러 버전, 중요한 환경 변수 등 관련 입력의 변경을 감지하면 캐시를 자동으로 무효화합니다. 따라서 Cargo 위에 Sccache 래퍼를 씌우기만 하면 나머지는 Sccache가 처리합니다.
영구 산출물은 S3 버킷 rust-lang-ci-sccache2에 저장합니다. 따라서 CI가 실행될 때 LLVM이 동일한 C/C++ 컴파일러로 컴파일되고 LLVM 소스 코드도 동일하다는 것을 Sccache가 확인하면, Sccache는 이미 컴파일된 개별 번역 단위를 S3에서 가져옵니다.
CI 관련 사용자 정의 도구
우리는 수년에 걸쳐 CI 사용 경험을 개선하기 위해 몇 가지 사용자 정의 도구를 개발했습니다.
PR에 오류 메시지를 표시하는 Rust Log Analyzer
rust-lang/rust의 빌드 로그는 매우 방대하여, 로그를 살펴보며 빌드 실패 원인을 찾는 것은 현실적이지 않습니다. 그래서 우리는 Rust Log Analyzer(RLA)라는 봇을 개발했습니다. 이 봇은 빌드가 실패하면 빌드 로그를 받아 오류 메시지를 자동으로 추출하고 PR 스레드에 게시합니다.
이 봇은 오류 문자열을 찾도록 하드코딩되어 있지 않습니다. 대신 다수의 빌드 실패 사례로 학습하여 어떤 줄이 여러 빌드에 공통으로 나타나고 어떤 줄이 그렇지 않은지 인식합니다. 생성된 코드 조각이 가끔 이상할 수 있지만, 이 봇은 이전에 한 번도 본 적 없는 오류라도 관련된 줄을 꽤 잘 찾아냅니다.
허용된 실패를 지원하는 Toolstate
rust-lang/rust 저장소는 CI에서 컴파일러뿐만 아니라 다양한 도구와 문서도 테스트합니다. 일부 문서는 git 서브모듈을 통해 가져옵니다. 문서가 수정될 때까지 rustc PR의 병합을 막는다면 닭이 먼저냐 달걀이 먼저냐 하는 문제에 빠지게 됩니다. 문서를 업데이트하려면 아직 병합되지 않은 버전의 rustc로 테스트해야 하므로 문서의 CI가 통과하지 못하기 때문입니다(그리고 우리는 보통 CI 통과를 요구합니다).
이 문제를 피하기 위해 서브모듈은 실패해도 되도록 허용되며, 그 상태는 rust-toolstate에 기록됩니다. 서브모듈이 망가지면 봇이 자동으로 메인테이너에게 알림을 보내 문제를 알 수 있게 하고, 실패 사실을 toolstate 저장소에 기록합니다. 그러면 릴리스 과정에서는 nightly의 망가진 도구를 무시하고, 배포되는 nightly에서 해당 도구를 제외합니다.
도구 실패는 대부분의 경우 허용되지만, 릴리스 일주일 전부터는 자동으로 금지됩니다. nightly에서 도구가 망가져 있는 것은 상관없지만 베타와 stable에서는 반드시 동작해야 하므로, nightly를 베타로 승격하기 며칠 전에는 nightly에서도 동작해야 합니다.
자세한 내용은 toolstate 문서에서 확인할 수 있습니다.
공개 CI 대시보드
Rust CI를 모니터링하려면 인프라 팀이 관리하는 공개 대시보드를 살펴보십시오.
대시보드에서 유용한 패널은 다음과 같습니다.
- 파이프라인 소요 시간: auto 빌드를 실행하는 데 걸리는 시간을 확인하십시오.
- Top slowest jobs: 실행하는 데 가장 오래 걸리는 작업이 무엇인지 확인합니다.
- Change in median job duration: 어떤 작업이 이전보다 느려졌는지 확인합니다. 이는 성능 회귀를 찾아내는 데 유용합니다.
- Top failed jobs: 가장 자주 실패하는 작업이 무엇인지 확인합니다.
대시보드에 대해 더 알아보려면 Datadog CI 문서를 참고하십시오.
CI 구성 확인하기
특정 작업에 대해 CI에서 어떤 bootstrap.toml 설정이 사용되는지 확인하려면 빌드 로그를 직접 살펴보는 것이 가장 쉬울 것입니다. 방법은 다음과 같습니다.
- Rust CI 성공 워크플로 실행 페이지로 이동하여 가장 최근 실행을 클릭하십시오.
- 왼쪽에서 원하는 작업을 선택하십시오.
- 톱니바퀴 아이콘을 클릭하고 “View raw logs“를 선택하십시오.
- “Configure the build“라는 빌드 구성 문자열을 검색하십시오.
- 모든 빌드 설정은
build.configure-args텍스트가 있는 줄에 나열되어 있습니다.
새 테스트 추가하기
일반적으로 rustc의 버그를 수정하는 모든 PR에는 어떤 형태로든 회귀 테스트가 함께 포함되어야 합니다. 이 테스트는 main에서는 실패하고 PR 적용 후에는 통과해야 합니다. 이러한 테스트는 과거의 실수를 되풀이하지 않도록 막는 데 매우 유용합니다.
가장 먼저 결정해야 할 것은 어떤 종류의 테스트를 추가할지입니다. 이는 변경 사항의 성격과 무엇을 검증하려는지에 따라 달라집니다. 다음은 대략적인 지침입니다.
- 대부분의 컴파일러 테스트는 compiletest로 수행됩니다.
- 표준 라이브러리에 대한 변경 사항은 보통 표준 라이브러리 자체 안에서 테스트합니다.
- 대부분의 표준 라이브러리 테스트는 일반적인 API 동작을 보여 주고 검증하는 doctest로 작성됩니다.
- 추가 단위 테스트는
library/${crate}/tests에 넣어야 합니다(여기서${crate}는 보통std입니다). alloc또는core크레이트에 대한 테스트는 별도의 크레이트, 즉 각각alloctests또는coretests에 넣어야 합니다.- 참고: 불안정 기능에 대한 단위 테스트를 추가할 때는
#![feature(...)]선언을library/${crate}tests/lib.rs가 아니라library/${crate}tests/tests/lib.rs에 추가해야 합니다.
- 참고: 불안정 기능에 대한 단위 테스트를 추가할 때는
- 코드가 격리된 시스템의 일부이고 컴파일러 출력을 테스트하는 것이 아니라면 단위 테스트나 통합 테스트를 사용하는 것을 고려하십시오.
- rustdoc을 실행해야 합니까?
rustdoc또는rustdoc-ui테스트를 우선적으로 사용하십시오. 경우에 따라rustdoc-js도 필요합니다. - 그 밖의 compiletest 테스트 스위트는 일반적으로 특수한 목적에 사용됩니다.
- gdb나 lldb를 실행해야 합니까?
debuginfo테스트 스위트를 사용하십시오. - LLVM IR이나 MIR IR을 검사해야 합니까?
codegen또는mir-opt테스트 스위트를 사용하십시오. - 생성된 바이너리를 어떤 방식으로든 검사해야 합니까? 아니면 다른 모든 테스트 스위트가 목적에 비해 너무 제한적입니까? 그렇다면
run-make를 사용하십시오.- 트리 내
rustc와 함께 트리 내cargo를 검증해야 한다면run-make-cargo를 사용하십시오.
- 트리 내
- 더 특화된 테스트 스위트에 대해서는 compiletest 장을 확인하십시오.
- gdb나 lldb를 실행해야 합니까?
추가할 테스트의 종류를 결정한 후에는, 다루기 쉽고 오랜 시간이 지나도 유효한 테스트를 작성하는 방법에 대한 지침으로 모범 사례를 참고하십시오(즉, 몇 년 후에 테스트가 실패하거나 수정되어야 할 때, 그 작업을 맡은 사람이 더 쉽게 다룰 수 있도록 하려면 어떻게 해야 하는지에 대한 지침입니다).
UI 테스트 따라 하기
다음은 가장 일반적인 컴파일러 테스트 중 하나인 UI 테스트를 작성하기 위한 기본 안내입니다. 이 튜토리얼에서는 async 오류 메시지에 대한 테스트를 추가합니다.
1단계: 테스트 파일 추가하기
첫 번째 단계는 tests/ui 트리 안의 적당한 위치에 Rust 소스 파일을 만드는 것입니다. 테스트를 만들 때는 좋은 위치와 이름을 정하도록 최선을 다하십시오(자세한 내용은 테스트 구성을 참고하십시오). 이름 짓기는 개발에서 가장 어려운 부분이므로, 여기서부터는 모든 것이 수월할 것입니다!
비동기 테스트를 tests/ui/async-await/await-without-async.rs에 둡시다:
// Provide diagnostics when the user writes `await` in a non-`async` function.
//@ edition:2018
async fn foo() {}
fn bar() {
foo().await
}
fn main() {}
이 테스트에서 눈여겨볼 몇 가지 사항은 다음과 같습니다:
- 파일 맨 위는 테스트의 목적을 설명하는 짧은 주석으로 시작해야 합니다.
//@ edition:2018주석은 지시문이라고 하며, 테스트를 빌드하는 방법에 대한 지침을 compiletest에 제공합니다. 여기서는async가 동작하도록 에디션을 설정해야 합니다(기본값은 2015 에디션입니다).- 그다음에는 테스트의 소스가 옵니다. 소스는 간결하고 요점만 담도록 하십시오. 버그 보고서의 예제를 최소화하려는 경우에는 어느 정도 노력이 필요할 수 있습니다.
- 이 테스트는 빈
fn main함수로 끝납니다. UI 테스트의 기본 크레이트 타입이bin인데, 테스트에서 “main not found” 오류가 발생하는 것은 원하지 않기 때문입니다. 또는#![crate_type="lib"]를 추가할 수도 있습니다.
2단계: 예상 출력 생성하기
다음 단계는 컴파일러로부터 예상 출력 스냅숏을 만드는 것입니다. 이는 --bless 옵션으로 수행할 수 있습니다:
./x test tests/ui/async-await/await-without-async.rs --bless
이렇게 하면 컴파일러를 빌드하고(아직 빌드되지 않은 경우), 테스트를 컴파일한 다음, 컴파일러의 출력을 tests/ui/async-await/await-without-async.stderr라는 파일에 저장합니다.
하지만 이 단계는 실패합니다! 다음과 비슷한 오류 메시지가 표시될 것입니다:
error: /rust/tests/ui/async-await/await-without-async.rs:7: 예상하지 못한 오류: ‘7:10: 7:16:
await는async함수와 블록 안에서만 허용됩니다 E0728’
이는 stderr에 소스 파일의 오류 주석과 일치하지 않는 오류가 포함되어 있기 때문입니다.
3단계: 오류 주석 추가하기
모든 오류에는 소스 안에 해당 오류의 텍스트를 담은 주석을 달아야 합니다. 이 경우 테스트 파일에 다음 주석을 추가할 수 있습니다:
fn bar() {
foo().await
//~^ ERROR `await` is only allowed inside `async` functions and blocks
}
//~^ 물결 캐럿 주석은 해당 오류가 이전 줄에 속한다는 것을 compiletest에 알려 줍니다(자세한 내용은 오류 주석 절을 참고하십시오).
저장한 후 테스트를 다시 실행하십시오:
./x test tests/ui/async-await/await-without-async.rs
이제 통과할 것입니다. 만세!
4단계: 출력 검토하기
이전 단계와 어느 정도 맞물려, 생성된 .stderr 파일을 살펴보고 예상한 모습인지 확인해야 합니다. 새로운 진단 메시지를 추가하는 경우, 특히 Rust를 처음 접하는 사람들에게 메시지가 전반적으로 얼마나 읽기 쉬운지도 함께 고려해 보기에 지금이 좋은 시점입니다.
예제의 tests/ui/async-await/await-without-async.stderr 파일은 다음과 같아야 합니다:
error[E0728]: `await` is only allowed inside `async` functions and blocks
--> $DIR/await-without-async.rs:7:10
|
LL | fn bar() {
| --- this is not `async`
LL | foo().await
| ^^^^^^ only allowed inside `async` functions and blocks
error: aborting due to previous error
For more information about this error, try `rustc --explain E0728`.
일반적인 컴파일러 출력과 비교하면 몇몇 부분이 조금 다르게 보인다는 것을 알 수 있습니다.
$DIR는 시스템마다 달라지는 경로 정보를 제거합니다.LL값은 줄 번호를 대체합니다. 이렇게 하면 소스 코드가 조금만 바뀌어도 큰 diff가 생기는 일을 막을 수 있습니다. 자세한 내용은 정규화 절을 참고하십시오.
이 단계에서는 테스트를 조정하고, 테스트를 다시 bless하고, 출력을 다시 검토하기 위해 마지막 몇 단계를 여러 번 반복해야 할 수도 있습니다.
5단계: 다른 테스트 확인하기
진단 메시지를 추가하거나 변경하면 테스트 스위트의 다른 테스트에 영향을 주는 경우가 있습니다. PR을 올리기 전 마지막 단계는 다른 부분에 영향을 주지 않았는지 확인하는 것입니다. 보통은 UI 스위트를 실행하는 것부터 시작하는 것이 좋습니다:
./x test tests/ui
다른 테스트가 실패하기 시작하면 무엇이 바뀌었는지, 그리고 새 출력이 타당한지 조사해야 할 수도 있습니다.
--bless 플래그로 출력을 다시 bless해야 할 수도 있습니다.
테스트의 목적을 설명하는 주석
테스트 파일의 첫 번째 주석에서는 테스트의 요점을 요약하고, 그 테스트에서 무엇이 중요한지 강조해야 합니다. 테스트와 관련된 이슈 번호가 있다면 그 이슈 번호를 포함하십시오.
이 주석이 아주 길 필요는 없습니다. 다음과 같은 정도로도 충분할 수 있습니다: “#18060에 대한 회귀 테스트: match 갈래가 잘못된 순서로 매칭되던 문제”.
이러한 주석은 나중에 테스트가 깨졌을 때 무엇이 문제인지 짚어 주는 경우가 많으므로 다른 사람들에게 매우 유용합니다. 또한 어떤 이유로 테스트를 리팩터링해야 할 때도 테스트의 어느 부분이 중요했는지 다른 사람들에게 알려 주므로 유용합니다. 테스트가 원래 검사하려던 것을 더 이상 검사하지 못해서 다시 작성해야 하는 경우가 많은데, 이때 그 테스트가 정확히 무엇을 검사하려고 했던 것인지 알면 도움이 됩니다.
테스트 작성 모범 사례
이 장에서는 테스트 작성 및 수정과 관련된 모범 사례를 설명합니다. 우리는 작성한 테스트가 몇 년이 지난 후에도 원래 작성자에게 문의하거나 git 기록을 잔뜩 파헤칠 필요 없이 이해하고 수정하기 쉽도록 하고자 합니다.
작성한 테스트를 검토할 때는, 몇 년 후 별다른 맥락 없이 실패한 테스트를 들여다보는 다른 기여자가 되었다고 가정하고 검토하는 것이 좋은 습관입니다(이는 며칠 또는 몇 달 후의 자기 자신에게도 도움이 됩니다!). 그런 다음 어떻게 하면 나와 그들의 삶을 더 편하게 만들 수 있을지 스스로에게 물어보십시오.
이를 실감 나게 이해할 수 있도록, 다른 기여자의 삶을 최대한 힘들게 만드는 테스트 작성법에 관한 여담부터 시작합시다.
여담: 간단한 테스트 방해 공작 야전 교범
다른 기여자의 삶을 최대한 힘들게 만들려면 다음과 같이 할 수 있습니다.
- 다른 맥락 없이 이슈 번호만으로 테스트 이름을 짓습니다. 예:
issue-123456.rs.- 테스트가 무엇을 검증하려는지에 대한 주석을 전혀 달지 않고, 관련 맥락으로 가는 링크도 남기지 않습니다.
- (충분히 최소화할 수 있는데도) 거대하고, 테스트가 실제로 검증하려는 핵심에서 주의를 흐트러뜨리는 불필요한 부분이 포함된 테스트를 넣습니다.
- 테스트가 확인하려는 내용에 중요하지 않은, 관련 없는 구문 오류와 기타 오류를 잔뜩 넣습니다.
- 코드 조각을 이상하게 포매팅합니다.
- 사용하지 않고 관련도 없는 기능을 잔뜩 넣습니다.
- 예를 들어
ignore-windowscompiletest 지시문을 넣되, 그것이 왜 필요한지는 전혀 설명하지 않습니다.
테스트 이름 짓기
독자가 이슈 번호를 입력하고 GitHub 검색을 뒤져 테스트가 무엇을 검증하려는지 찾아야 하는 대신, 테스트가 무엇을 검증하는지 즉시 이해할 수 있게 하십시오. 이렇게 하면 관련 테스트 묶음으로서 --test-args를 통해 테스트를 필터링할 수 있다는 추가 이점도 있습니다.
- 테스트가 검증하려는 대상이나 회귀를 방지하려는 대상에 따라 테스트 이름을 지으십시오.
- 간결하게 유지하십시오.
- 이슈 번호만을 테스트 이름으로 사용하지 마십시오.
- 자동 완성을 저하시키므로 테스트 이름을
issue-xxxxx접두사로 시작하지 마십시오.
이슈 번호만을 테스트 이름으로 사용하지 마십시오
대신 테스트 주석에 링크나
#123456형태로 포함하는 것을 권장합니다. 또는 이슈 번호를 포함하는 것이 타당하다면,macro-external-span-ice-123956.rs처럼 간략한 키워드도 함께 포함하십시오.tests/ui/typeck/issue-123456.rs // bad tests/ui/typeck/issue-123456-asm-macro-external-span-ice.rs // bad (for tab completion) tests/ui/typeck/asm-macro-external-span-ice-123456.rs // good tests/ui/typeck/asm-macro-external-span-ice.rs // good
issue-123456.rs는 테스트가 실제로 무엇을 검증하는지 즉시 알려 주지 않으므로 추가로 검색해야 합니다. 테스트 이름에 이슈 번호를 접두사로 포함하면 탭 자동 완성의 유용성이 떨어집니다(테스트 디렉터리에서ls를 실행했을 때issue-xxxxx접두사가 잔뜩 나오는 경우). 이슈 링크는 테스트 주석에 남기면 됩니다.//! Check that `asm!` macro including nested macros that come from external //! crates do not lead to a codepoint boundary assertion ICE. //! //! Regression test for <https://github.com/rust-lang/rust/issues/123456>.이 규칙의 한 가지 예외는 크래시 테스트입니다. 크래시 테스트의 목적은 어떤 이슈의 코드 조각이 더 이상 ICE/크래시를 일으키지 않는지 추적하는 것이므로 테스트 이름을 이슈 번호만으로 짓는 것이 표준이며, 이러한 테스트는 수정 PR에서 제거되거나 적절한 ui 테스트 또는 기타 테스트로 변환됩니다.
테스트 구성
- 대부분의 테스트 스위트에서는 테스트를 둘 의미상 적절한 하위 디렉터리를 찾으십시오.
- 예를 들어 특히 RFC 2093의 구현이라면
tests/ui/rfc-2093-infer-outlives/아래에 테스트 모음을 묶어 둘 수 있습니다. 디렉터리 이름에는 해당 RFC가 무엇에 관한 것인지를 포함하십시오.
- 예를 들어 특히 RFC 2093의 구현이라면
run-make/run-make-support테스트 스위트의 경우, 각rmake.rs는 각각tests/run-make/또는tests/run-make-cargo/바로 아래의 하위 디렉터리에 들어 있어야 합니다. 그보다 더 깊은 중첩은 현재 지원되지 않습니다. 테스트 이름에 이슈 번호만 사용하는 것도 피하십시오.
테스트 설명
다른 기여자가 자신의 변경으로 인해 테스트가 실패했을 때 그 테스트가 무엇에 관한 것인지 이해할 수 있도록, 테스트에 그 의도/목적에 대한 충분한 문서, 관련 맥락(이슈 번호나 기타 논의 포함)으로의 링크, 그리고 경우에 따라 관련 자료(예: 특정 동작에 대해서는 Win32 API로 링크하면 도움이 될 수 있습니다)가 갖춰져 있는지 확인해야 합니다.
좋은 주석이 달린 테스트의 개요
//! Brief summary of what the test is exercising.
//! Example: Regression test for #123456: make sure coverage attribute don't ICE
//! when applied to non-items.
//!
//! Optional: Remarks on related tests/issues, external APIs/tools, crash
//! mechanism, how it's fixed, FIXMEs, limitations, etc.
//! Example: This test is like `tests/attrs/linkage.rs`, but this test is
//! specifically checking `#[coverage]` which exercises a different code
//! path. The ICE was triggered during attribute validation when we tried
//! to construct a `def_path_str` but only emitted the diagnostic when the
//! platform is windows, causing an ICE on unix.
//!
//! Links to relevant issues and discussions. Examples below:
//! Regression test for <https://github.com/rust-lang/rust/issues/123456>.
//! See also <https://github.com/rust-lang/rust/issues/101345>.
//! See discussion at <https://rust-lang.zulipchat.com/#narrow/stream/131828-t-compiler/topic/123456-example-topic>.
//! See [`clone(2)`].
//!
//! [`clone(2)`]: https://man7.org/linux/man-pages/man2/clone.2.html
//@ ignore-windows
// Reason: (why is this test ignored for windows? why not specifically
// windows-gnu or windows-msvc?)
// Optional: Summary of test cases: What positive cases are checked?
// What negative cases are checked? Any specific quirks?
fn main() {
#[coverage]
//~^ ERROR coverage attribute can only be applied to function items.
let _ = {
// Comment highlighting something that deserves reader attention.
fn foo() {}
};
}
얼마나 많은 맥락/설명이 필요한지는 작성자와 리뷰어의 재량에 달려 있습니다. 테스트에서 다루는 사소하지 않은 부분에는 다른 기여자의 이해를 돕도록 어느 정도 설명을 붙이는 것이 좋은 경험 법칙입니다. 여기에는 다음에 대한 설명이 포함될 수 있습니다.
- ICE나 버그가 꽤 복잡한 방식으로 발생하는 경우, 그것이 어떻게 발생하는지.
- 관련 이슈와 테스트(예: 이 테스트는 다른 테스트와 비슷하지만 … 때문에 별도로 유지됩니다).
- 플랫폼별 동작.
- 외부 의존성과 API의 동작: 시스템 콜, 링커, 도구, 환경 등.
테스트 내용
- 테스트를 가능한 한 최소화하도록 노력하십시오.
- 중요하지 않은 코드를 최소화하고, 특히 stderr 스냅숏을 어지럽힐 수 있는 불필요한 구문 오류와 타입 오류를 최소화하십시오.
- 관련 없는 경고를 억제하려면
#![allow(...)]또는#![expect(...)]를 사용하십시오. - 가능하면 의미가 드러나는 이름을 사용하십시오(예:
fn bare_coverage_attributes() {}).
불안정한 테스트
모든 테스트는 재현 가능하고 신뢰할 수 있도록 노력해야 합니다. 불안정한 테스트는 최악의 테스트이며, 애초에 테스트가 없는 것보다도 나쁘다고 할 수 있습니다.
- 불안정한 테스트는 전혀 관련 없는 PR에서도 실패할 수 있으며, 이는 다른 기여자를 혼란스럽게 하고 테스트 실패가 관련이 있는지 알아내느라 시간을 낭비하게 만듭니다.
- 불안정한 테스트는 불안정하고 신뢰할 수 없다는 사실 외에는 테스트 결과로부터 아무런 유용한 정보도 제공하지 않습니다. 테스트가 통과했지만 불안정한 테스트라면, 그저 운이 좋았던 것일까요? 테스트가 불안정한데 실패했다면, 그것은 단지 우연한 실패였을까요?
- 불안정한 테스트는 테스트 스위트 전체에 대한 신뢰를 떨어뜨립니다. 불안정한 테스트 때문에 테스트 스위트가 무작위로 우연히 실패할 수 있다면, 테스트 스위트 전체가 통과한 것일까요, 아니면 그저 운이 좋았거나 나빴던 것일까요?
- 불안정한 테스트는 전체 CI에서 무작위로 실패하여 귀중한 자원을 낭비할 수 있습니다.
compiletest 지시문
지시문 목록은 compiletest 지시문을 참조하십시오.
ignore-*/needs-*/only-*지시문을 사용할 때는 이유가 아주 명백한 경우가 아니라면 해당 지시문이 왜 필요한지 간단히 설명하십시오. 예:"//@ ignore-wasi (wasi codegens the main symbol differently)".//@ ignore-auxiliary를 사용할 때는 이 파일을 사용하는 주 테스트 파일을 명시하십시오. 예://@ ignore-auxiliary (used by `./foo.rs`).
FileCheck 모범 사례
자세한 내용은 LLVM FileCheck 가이드를 참고하십시오.
- 특정 레지스터 번호나 기본 블록 번호가 특별하거나 테스트에 결정적으로 중요한 경우가 아니라면 이를 매칭하지 마십시오. 적절한 경우에는 패턴을 사용하여 이를 매칭하는 방법을 고려하십시오.
TODO
구체적인 조언은 추후 추가될 예정입니다.
Compiletest
소개
compiletest는 Rust 테스트 스위트의 주요 테스트 하네스입니다. 테스트 작성자는 이를 통해 많은 수의 테스트를 체계적으로 구성하고(Rust 컴파일러에는 수천 개의 테스트가 있습니다), 테스트를 효율적으로 실행할 수 있으며(병렬 실행을 지원합니다), 개별 테스트와 테스트 그룹 모두의 동작과 예상 결과를 설정할 수 있습니다.
macOS 사용자를 위한 참고 사항
macOS에서는
SIP(System Integrity Protection)가 Apple에 네트워크 요청을 보내 컴파일된 바이너리를 매번 검사할 수 있으므로, 테스트를 실행할 때 성능이 크게 저하될 수 있습니다.다음 설정을 조정하여 이 문제를 해결할 수 있습니다:
Privacy & Security -> Developer Tools -> Add Terminal (Or VsCode, etc.).
compiletest는 테스트 코드가 컴파일 타임 또는 런타임에 성공하는지 실패하는지 검사할 수 있습니다.
테스트는 일반적으로 Rust 소스 파일로 구성되며, 테스트 코드 앞이나 안(또는 둘 다)에 있는 주석에 어노테이션을 담습니다. 이러한 주석은 테스트를 실행할지 여부와 실행 방법, 예상되는 동작 등을 compiletest에 지시하는 역할을 합니다. 이러한 어노테이션에 대한 자세한 내용은 지시문과 아래의 테스트 스위트 문서를 참고하십시오.
새 테스트를 작성하는 튜토리얼과 좋은 테스트를 작성하기 위한 조언은 새 테스트 추가하기 장과 모범 사례 장을, 테스트 스위트를 실행하는 방법은 테스트 실행하기 장을 참고하십시오.
인자는 --test-args를 사용하거나 -- 뒤에 배치하여 compiletest에 전달할 수 있습니다. 예를 들면 다음과 같습니다.
x test --test-args --force-rerunx test -- --force-rerun
또한 bootstrap은 몇 가지 일반적인 인자를 직접 받습니다. 예를 들면 다음과 같습니다.
x test --no-capture --force-rerun --run --pass.
Compiletest 자체는 관련된 산출물(주로 컴파일러)이 변경되지 않았을 때 테스트 실행을 피하려고 합니다. 입력이 전혀 변경되지 않았을 때에도 테스트를 다시 실행하려면 x test --test-args --force-rerun을 사용하면 됩니다.
테스트 스위트
모든 테스트는 tests 디렉터리에 있습니다. 테스트는 “스위트“로 구성되며, 각 스위트는 별도의 하위 디렉터리에 있습니다. 테스트 스위트마다 컴파일러 동작과 정확성 검사 방식이 달라서 각각 조금씩 다르게 동작합니다. 예를 들어 tests/incremental 디렉터리에는 증분 컴파일 테스트가 들어 있습니다. 여러 스위트는 src/tools/compiletest/src/common.rs의 pub enum Mode 선언에 정의되어 있습니다.
사용할 수 있는 테스트 스위트는 다음과 같으며, 자세한 내용은 링크를 참고하십시오.
컴파일러 전용 테스트 스위트
| 테스트 스위트 | 목적 |
|---|---|
ui | 컴파일 결과 또는 생성된 실행 파일의 실행 결과(또는 둘 다)에서 나온 stdout/stderr 스냅숏을 검사합니다 |
ui-fulldeps | extern crate rustc_span;을 사용하거나 플러그인으로 사용되는 등, 링크 가능한 rustc 빌드가 필요한 ui 테스트입니다 |
pretty | 예쁜 출력(pretty printing)을 확인합니다 |
incremental | 증분 컴파일 동작을 확인합니다 |
debuginfo | 디버거를 실행하여 디버그 정보 생성을 확인합니다 |
codegen-* | 코드 생성을 확인합니다 |
codegen-units | 코드 생성 단위 분할을 확인합니다 |
assembly | 어셈블리 출력을 확인합니다 |
mir-opt | MIR 생성 및 최적화를 확인합니다 |
coverage | 커버리지 계측을 확인합니다 |
coverage-run-rustdoc | 계측된 문서 테스트도 함께 실행하는 coverage 테스트입니다 |
crashes | 의도치 않은 수정을 잡아내기 위해 특정 입력에서 컴파일러가 ICE/패닉/크래시를 일으키는지 확인합니다 |
범용 테스트 스위트
run-make는 Rust 프로그램을 사용하는 범용 테스트입니다.
build-std(표준 라이브러리 빌드) 테스트 스위트
build-std는 -Zbuild-std가 동작하는지 확인하는 테스트입니다.
Rustdoc 테스트 스위트
| 테스트 스위트 | 목적 |
|---|---|
rustdoc-html | rustdoc의 HTML 출력을 확인합니다 |
rustdoc-gui | 웹 브라우저를 사용하여 rustdoc의 GUI를 확인합니다 |
rustdoc-js | rustdoc의 검색 엔진과 인덱스를 검사합니다 |
rustdoc-js-std | std 라이브러리 문서에서 rustdoc의 검색 엔진과 인덱스를 검사합니다 |
rustdoc-json | rustdoc의 JSON 출력을 검사합니다 |
rustdoc-ui | rustdoc의 터미널 출력을 검사합니다(참고) |
ui/rustdoc/에도 rustdoc 전용 테스트가 일부 들어 있습니다. 이 테스트들은 rustdoc 실행 중에 발생하는 특정 린트가 rustc를 실행할 때도 실행되는지 확인합니다.
run-make/rustdoc/에는 rustdoc과 관련된 run-make 테스트가 들어 있습니다.
프리티 프린터 테스트
tests/pretty의 테스트는 rustc의 “프리티 프린팅” 기능을 검증합니다. rustc의 -Z unpretty CLI 옵션은 입력 소스를 매크로 확장 이후의 Rust 소스 등 다양한 형식으로 변환하게 합니다.
프리티 프린터 테스트에는 아래에서 설명하는 여러 지시문이 있습니다. 이 명령들은 테스트의 동작을 크게 바꿀 수 있지만, 명령이 없을 때의 기본 동작은 다음과 같습니다.
- 소스 파일에 대해
rustc -Zunpretty=normal을 실행합니다. - 이전 단계의 출력에 대해
rustc -Zunpretty=normal을 실행합니다. - 앞의 두 단계의 출력은 같아야 합니다.
- 출력에 대해
rustc -Zno-codegen을 실행하여 타입 검사가 가능한지 확인합니다(cargo check와 비슷합니다).
위 명령 중 하나라도 실패하면 테스트가 실패합니다.
프리티 프린팅 테스트의 지시문은 다음과 같습니다.
pretty-mode는 pretty-print 테스트를 실행할 모드(즉,-Zunpretty에 전달할 인자)를 지정합니다. 지정하지 않으면 기본값은normal입니다.pretty-compare-only를 사용하면 프리티 테스트가 프리티 프린트된 출력만 비교합니다(위의 3단계까지만 수행하고 멈춥니다). 전개된 출력을 컴파일하여 타입 검사를 시도하지는 않습니다. 이는 유효한 Rust로 전개되지 않는 pretty-mode나, 전개된 출력을 컴파일할 수 없는 그 밖의 상황에 필요합니다.pp-exact는 프리티 프린트 테스트가 특정 출력을 내도록 보장하는 데 사용합니다. 값 없이 지정하면 프리티 프린트 출력이 원본 소스와 일치해야 한다는 뜻입니다.//@ pp-exact:foo.pp처럼 값과 함께 지정하면 프리티 프린트된 출력이 지정한 파일의 내용과 일치하는지 확인합니다. 그렇지 않고pp-exact를 지정하지 않으면, 프리티 프린트된 출력을 한 번 더 프리티 프린트한 뒤 두 번의 프리티 프린팅 결과를 비교하여 프리티 프린트된 출력이 안정된 상태로 수렴하는지 확인합니다.
증분 테스트
tests/incremental의 테스트는 증분 컴파일을 검사합니다. 이 테스트는 revisions 지시문을 사용하여 compiletest가 컴파일러를 여러 단계에 걸쳐 실행하도록 지시합니다.
Compiletest는 -C incremental 플래그를 사용해 빈 디렉터리에서 시작한 다음, 각 리비전마다 이전 단계의 증분 결과를 재사용하면서 컴파일러를 실행합니다.
각 리비전 이름은 다음 중 하나로 시작해야 합니다:
cpass- 테스트가 성공적으로 컴파일되어야 합니다(체크 빌드, 코드 생성 없음).bfail— 테스트가 컴파일에 실패해야 합니다(전체 빌드, 코드 생성 포함).bpass— 테스트가 성공적으로 컴파일되어야 합니다(전체 빌드, 코드 생성 포함).rpass— 테스트가 성공적으로 컴파일되고 실행되어야 합니다.
리비전을 고유하게 만들려면 rpass1, rpass2와 같은 접미사를 붙여야 합니다.
소스 변경을 시뮬레이션하기 위해 compiletest는 현재 리비전 이름을 담은 --cfg 플래그도 전달합니다.
예를 들어, 다음 테스트는 함수 변경을 시뮬레이션하면서 두 번 실행됩니다:
//@ revisions: rpass1 rpass2
#[cfg(rpass1)]
fn foo() {
println!("one");
}
#[cfg(rpass2)]
fn foo() {
println!("two");
}
fn main() { foo(); }
증분 테스트는 특정 부분 문자열이 컴파일러 출력 어디에도 나타나서는 안 된다는 것을 지정하는 forbid-output 지시문을 지원합니다. 이는 특정 오류가 나타나지 않음을 보장하는 데 유용할 수 있지만, 오류 메시지는 시간이 지나면서 바뀌므로 깨지기 쉽습니다. 또한 테스트가 더 이상 올바른 대상을 검사하지 않는데도 계속 통과할 수 있습니다.
증분 테스트에서는 #[rustc_clean(...)] 속성을 사용할 수 있습니다. 이 속성은 현재 컴파일 세션의 핑거프린트를 이전 세션의 핑거프린트와 비교합니다. 첫 번째 리비전은 항상 dirty 상태이므로 활성화된 rustc_clean 속성을 절대 가져서는 안 됩니다.
기본 모드에서는 핑거프린트가 같아야 함을 단언합니다. 이 속성은 다음 인자를 받습니다.
cfg="<cond>"— cfg 조건<cond>를 확인하고, 해당 설정 조건이 참으로 평가될 때에만 검사를 실행합니다. 이를 사용하여 특정 리비전에서만rustc_clean속성을 실행할 수 있습니다.except="<query1>,<query2>,..."— 나열된 쿼리의 쿼리 결과가 같지 않고 달라야 함을 단언합니다.loaded_from_disk="<query1>,<query2>,..."— 나열된 쿼리의 쿼리 결과가 (단순히 green으로 표시된 것이 아니라) 실제로 디스크에서 로드되었음을 단언합니다. 이는 테스트가 특정 쿼리 결과에 대한 역직렬화 로직을 실제로 검사하고 있는지 확인하는 데 유용할 수 있습니다. 이 인자는except와 함께 사용할 수 있습니다.
rustc_clean을 사용하는 테스트의 간단한 예로는 hello_world 테스트가 있습니다.
Debuginfo 테스트
Important
#159455부터 이 테스트는 옵트인 방식으로 바뀌었습니다. 자세한 맥락은 다음을 참고하십시오: debuginfo 테스트 스위트 상태 안정화
tests/debuginfo의 테스트는 지원되는 디버거가 debuginfo를 어떻게 해석하는지 테스트하고, 시각화가 여전히 예상대로 작동하는지 확인합니다. 이 테스트는 프로그램을 빌드하고, 디버거를 실행한 뒤, 디버거에 명령을 내립니다. 하나의 테스트가 cdb, gdb, lldb에서 모두 동작할 수 있습니다.
대부분의 테스트에는 적절한 debuginfo를 생성하기 위해 //@ compile-flags: -g 지시문 또는 이와 비슷한 지시문이 있어야 합니다.
특정 줄에 중단점을 설정하려면 해당 줄에 // #break 주석을 추가하십시오.
debuginfo 테스트는 일련의 디버거 명령과, 디버거가 출력할 것으로 예상되는 내용을 지정하는 “check” 줄로 구성됩니다.
명령은 //@ $DEBUGGER-command:$COMMAND 형식의 주석이며, 여기서 $DEBUGGER는 사용 중인 디버거이고 $COMMAND는 실행할 디버거 명령입니다.
디버거 값은 다음 중 하나일 수 있습니다.
cdbgdblldb
출력을 확인하는 명령은 //@ $DEBUGGER-check:$OUTPUT 형식이며, 여기서 $OUTPUT은 예상되는 출력입니다.
예를 들어, 다음은 테스트를 빌드하고, 디버거를 시작하고, 중단점을 설정하고, 프로그램을 실행하고, 값을 검사한 뒤, 디버거가 출력하는 내용을 확인합니다.
//@ compile-flags: -g
//@ lldb-command: run
//@ lldb-command: print foo
//@ lldb-check: $0 = 123
fn main() {
let foo = 123;
b(); // #break
}
fn b() {}
또한 단순한 문자열 비교로 가능한 것보다 더 세밀하게 변수(및 그 시각화 도구)를 검증하기 위한 특수 명령인 //@ $DEBUGGER-repr:$VAR_NAME이 있습니다. 가능하면 언제나 -command/-check보다 이 지시문을 우선적으로 사용하십시오.
Note
이 글을 작성하는 시점(2026년 7월)에 이 명령은 LLDB에서만 사용할 수 있으며, GDB용 구현이 곧 제공될 예정입니다. 이 로직을 CDB로 이식할 확정된 계획은 없습니다.
이 명령은 사실상 다음과 같이 디슈가링됩니다.
//@ $DEBUGGER-command:repr $VAR_NAME
//@ $DEBUGGER-check:$VAR_NAME ok
repr $VAR_NAME 명령은 디버거의 API를 사용하여 변수의 출력 결과에 반영되지 않는 데이터를 검사하는 특수 로직이 가로챕니다. 메모리상의 변수는 tests/debuginfo/<test_name>/input/<debugger>_input/<target_group>.json에 저장된 입력 데이터와 비교되며, 실패 시 상세한 오류 메시지를 제공합니다.
Important
-repr지시문은-command/-check와 달리--bless옵션과 호환됩니다.-repr명령이 있는 파일을--bless하면 해당 대상의 입력 데이터 파일이 자동으로 생성/갱신됩니다.
이 명령의 구현 세부 사항은 디버그 정보 장의 테스트 절에서 더 자세히 설명합니다.
현재 사용 중인 디버거에 따라 테스트를 비활성화하는 데 다음 지시문을 사용할 수 있습니다.
min-cdb-version: 10.0.18317.1001— cdb 버전이 지정된 버전보다 낮으면 테스트를 무시합니다min-gdb-version: 8.2— gdb 버전이 지정된 버전보다 낮으면 테스트를 무시합니다ignore-gdb-version: 9.2— gdb 버전이 지정된 버전과 같으면 테스트를 무시합니다ignore-gdb-version: 7.11.90 - 8.0.9— gdb 버전이 범위(양 끝 포함) 안에 있으면 테스트를 무시합니다min-apple-lldb-version: 1703.0.236.21/min-llvm-lldb-version: 21.1.0— lldb 버전이 지정된 버전보다 낮으면 테스트를 무시합니다. 참고: (Xcode와 함께 배포되는) Apple의 LLDB 포크는 LLVM의 LLDB 버전 번호에 쉽게 대응시킬 수 없는 별도의 버전 체계를 사용합니다. 따라서 버전 조건은 배포사별로 지정합니다. 버전 대응 관계를 수동으로 확인하는 방법에 대한 자세한 정보는 여기에서 확인할 수 있습니다rust-lldb— lldb에 Rust 플러그인이 포함되어 있지 않으면 테스트를 무시합니다. 참고: LLDB의 “Rust” 버전은 더 이상 존재하지 않으므로 이 테스트는 항상 무시됩니다. 이 지시어는 제거하는 것이 좋을 것입니다.
compiletest에 --debugger 옵션을 전달하면 테스트를 실행할 디버거를 하나만 지정할 수 있습니다. 예를 들어, ./x test tests/debuginfo -- --debugger gdb는 GDB 명령만 테스트합니다.
미리 빌드된 lldb 배포판으로 lldb debuginfo 테스트를 로컬에서 실행할 때의 참고 사항
lldb debuginfo 테스트를 로컬에서 실행하려면 현재 Windows에서는 다음 조건을 갖추어야 합니다.
LLDB 21
- Python 3.10이 설치되어 있어야 합니다.
PATH환경 변수를 통해python310.dll을 사용할 수 있어야 합니다. 이 파일은python.org에서 받는 표준 Python 설치 프로그램에 포함되어 있지 않으므로 직접PATH에 추가해야 합니다.LLDB 22
- Python 3.11이 설치되어 있어야 합니다.
PATH를 통해python311.dll을 사용할 수 있어야 합니다.- Python은
pymanager로 설치하기를 권장합니다.pymanager에는PATH설정을 도와주는 후속 설치 스크립트가 있습니다.그렇지 않으면 lldb debuginfo 테스트가 원인을 알 수 없는 크래시를 일으킬 수 있습니다.
Windows 11에서
cdb.exe를 설치할 때의 참고 사항
cdb.exe는 적절한 “Windows 11 SDK“와 함께 설치됩니다. 이 SDK는 Visual Studio 설치 프로그램(예: Visual Studio 2022 설치 프로그램)의 “C++를 사용한 데스크톱 개발”(Desktop Development with C++) 워크로드 프로필에 포함되어 있습니다.그러나 기본 설정만으로는 충분하지 않습니다.
cdb.exe가 필요하다면 설치된 앱으로 이동하여 가장 최신의 “Windows Software Development Kit“을 찾아야 합니다(운영 체제 이름이 Windows 11이더라도 이 항목에는 여전히Windows 10.0.22161.3233이라고 표시될 수 있습니다). 그런 다음cdb.exe를 설치하려면 “수정”(Modify) -> “변경”(Change)을 클릭하고 “Debugging Tools for Windows“를 선택해야 합니다.
코드 생성 테스트
tests/codegen-llvm의 테스트는 LLVM 코드 생성을 테스트합니다. 이 테스트들은 --emit=llvm-ir 플래그로 테스트를 컴파일하여 LLVM IR을 내보냅니다. 그런 다음 LLVM FileCheck 도구를 실행합니다. 테스트에는 생성된 코드를 검사하는 다양한 // CHECK 주석이 달려 있습니다. 튜토리얼과 자세한 정보는 FileCheck 문서를 참고하십시오.
비슷한 테스트 모음으로 어셈블리 테스트도 참고하십시오.
기본적으로 코드 생성 테스트에는 테스트 소스에 #![no_std]/#![no_core] 속성이 지정되지 않은 한 //@ needs-target-std(대상이 std를 지원해야 함)가 암묵적으로 적용됩니다. 이 동작을 재정의하여 //@ needs-target-std를 명시적으로 작성하면 테스트가 #![no_std]/#![no_core]이더라도 대상이 std를 지원할 때만 테스트가 실행됩니다.
#![no_std] 크로스 컴파일 테스트를 다루어야 한다면 minicore 테스트 보조 도구 장을 참고하십시오.
어셈블리 테스트
tests/assembly-llvm의 테스트는 LLVM 어셈블리 출력을 테스트합니다. 이 테스트들은 --emit asm 플래그로 테스트를 컴파일하여 어셈블리 출력이 담긴 .s 파일을 내보냅니다. 그런 다음 LLVM FileCheck 도구를 실행합니다.
각 테스트에는 어셈블리 출력의 종류를 나타내는 //@ assembly-output: 지시문을 emit-asm 또는 ptx-linker 값과 함께 달아야 합니다.
그런 다음 어셈블리 출력을 검사할 수 있도록 테스트에 여러 // CHECK 주석을 달아야 합니다. 튜토리얼과 자세한 내용은 FileCheck 문서를 참고하십시오.
비슷한 테스트 모음으로 코드 생성 테스트도 참고하십시오.
#![no_std] 크로스 컴파일 테스트를 다뤄야 한다면 minicore 테스트 보조 장을 참고하십시오.
코드 생성 단위(codegen-units) 테스트
tests/codegen-units의 테스트는 단형화 수집기와 CGU 분할을 테스트합니다.
이 테스트는 단형화 수집 패스의 결과를 출력하는 플래그인 -Zprint-mono-items를 붙여 rustc를 실행한 다음, 파일 안의 특수 주석을 그 결과와 비교하는 방식으로 동작합니다.
그런 다음 테스트에 //~ MONO_ITEM name 형식의 주석을 달아야 합니다. 여기서 name은 fn <u32 as Trait>::foo처럼 rustc가 출력하는 단형화된 문자열입니다.
CGU 분할을 검사하려면 //~ MONO_ITEM name @@ cgu 형식의 주석을 사용합니다. 여기서 cgu는 CGU 이름과 대괄호로 묶은 링키지 정보를 공백으로 구분한 목록입니다. 예: //~ MONO_ITEM static function::FOO @@ statics[Internal]
Mir-opt 테스트
tests/mir-opt의 테스트는 생성된 MIR의 일부를 검사하여 MIR이 올바르게 생성되는지, 기대한 최적화가 이루어지는지 확인합니다. 자세한 내용은 MIR 최적화 장을 참고하십시오.
Compiletest는 MIR 출력을 덤프하고 최적화 기준선을 설정하기 위해 다음 플래그들을 붙여 테스트를 빌드합니다:
-Copt-level=1-Zdump-mir=all-Zmir-opt-level=4-Zvalidate-mir-Zdump-mir-exclude-pass-number
테스트에는 기대하는 MIR 출력을 담을 파일을 지정하는 // EMIT_MIR 주석을 달아야 합니다. x test --bless를 사용하여 초기 기대 파일을 생성할 수 있습니다.
EMIT_MIR 주석은 다음과 같은 여러 형식으로 작성할 수 있습니다:
-
// EMIT_MIR $MIR_PATH.mir— 주어진 파일이 MIR 덤프의 출력과 정확히 일치하는지 검사합니다. 예를 들어my_test.main.SimplifyCfg-elaborate-drops.after.mir는 테스트 디렉터리에서 해당 파일을 불러와 rustc의 덤프와 비교합니다.최적화 후의 최종 상태에 관심이 있다면 “after” 파일(최적화 이후의 파일)을 검사하는 것이 유용합니다. 드물게 완전성을 위해 “before” 파일을 사용하고 싶은 경우도 있습니다.
-
// EMIT_MIR $MIR_PATH.diff— 여기서$MIR_PATH는my_test_name.my_function.EarlyOtherwiseBranch와 같은 MIR 덤프의 파일 이름입니다. Compiletest는.before.mir파일과.after.mir파일의 diff를 구하고, 그 diff 출력을EMIT_MIR주석에 지정된 기대.diff파일과 비교합니다.이 형식은 최적화가 MIR을 어떻게 바꾸는지 보고 싶을 때 유용합니다.
-
// EMIT_MIR $MIR_PATH.dot— 추가 MIR 데이터를 덤프하는 특정 플래그(예:.dot파일을 생성하는-Z dump-mir-graphviz)를 사용할 때, 출력이 주어진 파일과 일치하는지 검사합니다.
기본적으로 32비트 타깃과 64비트 타깃은 같은 덤프 파일을 사용하므로, 상수 안에 포인터가 있거나 그 밖에 비트 폭에 의존하는 요소가 있으면 문제가 될 수 있습니다. 그런 경우 테스트에 // EMIT_MIR_FOR_EACH_BIT_WIDTH를 추가하면 32비트 시스템용 파일과 64비트 시스템용 파일이 따로 생성됩니다.
run-make 테스트
tests/run-make 및 tests/run-make-cargo의 테스트는 Rust 레시피를 사용하는 범용 테스트입니다. 레시피는 rustc 호출 같은 임의의 Rust 코드를 실행할 수 있는 작은 프로그램(rmake.rs)이며, run_make_support 라이브러리의 지원을 받습니다. Rust 레시피를 사용하면 최고 수준의 유연성을 얻을 수 있습니다.
요구 사항에 더 잘 맞는 다른 테스트 스위트가 없을 때 run-make 테스트를 사용하십시오.
run-make-cargo 테스트 스위트는 트리 내 cargo를 트리 내 rustc와 함께 테스트해야 하는 사용 사례를 지원하기 위해 트리 내 cargo를 추가로 빌드합니다. run-make 테스트 스위트는 트리 내 cargo에 접근할 수 없습니다(그래서 더 빠르게 반복할 수 있는 테스트 스위트가 될 수 있습니다).
build-std 테스트
tests/build-std의 테스트는 -Zbuild-std가 제대로 작동하는지 확인합니다. 현재 이 테스트 스위트는 레시피가 하나뿐인 run-make 테스트 스위트일 뿐입니다. 이 레시피는 테스트 케이스를 생성하여 병렬로 실행합니다.
Rust 레시피 사용하기
각 테스트는 레시피라고 부르는 rmake.rs Rust 프로그램과 함께 별도의 디렉터리에 있어야 합니다. 레시피는 run_make_support 라이브러리가 링크된 상태로 compiletest에 의해 컴파일되고 실행됩니다.
새로운 유틸리티나 기능이 필요하다면 run_make_support 라이브러리를 확장하고 개선하는 것을 고려하십시오.
UI 테스트와 마찬가지로 rmake.rs에서도 //@ only-<target>이나 //@ ignore-<target> 같은 compiletest 지시문을 지원합니다. 하지만 리비전이나 지시문을 통한 보조 파일 빌드는 현재 지원되지 않습니다.
rmake.rs와 run-make-support는 베타 버전이거나 심지어 안정 버전일 수도 있는 stage 0 rustc로 컴파일할 수 있어야 하므로, nightly/불안정 기능을 사용해서는 안 됩니다.
기본적으로 run-make 테스트는 각 하위 프로세스 명령과 해당 stdout/stderr를 출력합니다. panic=abort 테스트 스위트(예: cg_clif)에서 --no-capture와 함께 실행하면 이 출력이 터미널을 가득 채울 수 있습니다. 통과한 테스트의 이 출력을 숨기려면 --verbose-run-make-subprocess-output을 생략하십시오. 실패한 테스트는 이와 관계없이 항상 출력합니다:
./x test tests/run-make --no-capture --verbose-run-make-subprocess-output=false
rmake.rs 테스트를 컴파일할 수 있는지 빠르게 확인하기
rmake.rs를 stage0 컴파일러로 강제로 컴파일하면, stage1 rustc를 빌드하지 않고도 rmake.rs 테스트를 컴파일할 수 있는지 빠르게 확인할 수 있습니다:
$ COMPILETEST_FORCE_STAGE0=1 x test --stage 0 tests/run-make/<test-name>
물론 일부 테스트는 이 방법으로는 제대로 실행되지 않습니다.
rmake.rs에서 rust-analyzer 사용하기
다른 테스트 프로그램과 마찬가지로, run-make 테스트에서 사용하는 rmake.rs 스크립트에는 기본적으로 rust-analyzer 통합이 없습니다.
특정 테스트를 작업할 때 이 문제를 해결하려면, 해당 테스트의 디렉터리에 다음 내용으로 Cargo.toml 파일을 임시로 만드십시오(예: tests/run-make/sysroot-crates-are-unstable/Cargo.toml):
이 Cargo.toml이나 해당 Cargo.lock을 실제 PR에 추가하지 않도록 주의하십시오!
# Convince cargo that this isn't part of an enclosing workspace.
[workspace]
[package]
name = "rmake"
version = "0.1.0"
edition = "2021"
[dependencies]
run_make_support = { path = "../../../src/tools/run-make-support" }
[[bin]]
name = "rmake"
path = "rmake.rs"
그런 다음 "rust-analyzer.linkedProjects"에 해당 항목을 추가하십시오(예: .vscode/settings.json에서):
"rust-analyzer.linkedProjects": [
"tests/run-make/sysroot-crates-are-unstable/Cargo.toml"
],
커버리지 테스트
tests/coverage의 테스트는 커버리지 계측을 서로 다른 방식으로 테스트하는 여러 테스트 모드에서 공유됩니다. coverage 테스트 스위트를 실행하면 각 테스트가 모든 커버리지 모드에서 자동으로 실행됩니다.
각 모드에는 해당 모드에서만 커버리지 테스트를 실행하는 별칭도 있습니다:
./x test coverage # runs all of tests/coverage in all coverage modes
./x test tests/coverage # same as above
./x test tests/coverage/if.rs # runs the specified test in all coverage modes
./x test coverage-map # runs all of tests/coverage in "coverage-map" mode only
./x test coverage-run # runs all of tests/coverage in "coverage-run" mode only
./x test coverage-map -- tests/coverage/if.rs # runs the specified test in "coverage-map" mode only
어떤 이유로 특정 테스트를 커버리지 테스트 모드 중 하나에서 실행하지 않아야 한다면 //@ ignore-coverage-map 또는 //@ ignore-coverage-run 지시문을 사용하십시오.
coverage-map 스위트
coverage-map 모드에서 이 테스트들은 소스 코드 영역과 LLVM이 내보내는 커버리지 카운터 사이의 매핑을 검증합니다. 이 테스트들은 --emit llvm-ir로 테스트를 컴파일한 다음, 사용자 정의 도구(src/tools/coverage-dump)를 사용하여 IR에 포함된 커버리지 매핑을 추출하고 보기 좋게 출력합니다. 이 테스트들은 프로파일러 런타임이 필요하지 않으므로 PR CI 작업에서 실행되며, 로컬에서도 쉽게 실행하거나 bless할 수 있습니다.
이 커버리지 맵 테스트들은 MIR 로워링이나 MIR 최적화의 변경에 민감할 수 있어서, 매핑은 다르지만 동일한 커버리지 보고서를 만들어 내는 결과가 나올 수 있습니다.
경험적으로, 커버리지 관련 코드를 변경하지 않는 PR은 coverage-run 테스트가 여전히 통과하는 한 실제 변경 내용을 걱정하지 말고 필요에 따라 coverage-map 테스트를 자유롭게 다시 bless해도 됩니다.
coverage-run 스위트
coverage-run 모드에서 이 테스트들은 커버리지 보고에 대한 종단 간 테스트를 수행합니다. 이 테스트들은 커버리지 계측을 적용하여 테스트 프로그램을 컴파일하고, 그 프로그램을 실행하여 원시 커버리지 데이터를 생성한 다음, LLVM 도구를 사용하여 그 데이터를 사람이 읽을 수 있는 코드 커버리지 보고서로 가공합니다.
계측된 바이너리는 LLVM 프로파일러 런타임과 링크되어야 하므로, bootstrap.toml에서 프로파일러 런타임이 활성화되어 있지 않으면 coverage-run 테스트는 자동으로 건너뜁니다:
build.profiler = true
이는 또한 이 테스트들이 보통 PR CI 작업에서는 실행되지 않는다는 뜻이기도 하지만, 병합에 사용되는 전체 CI 작업의 일부로는 실행됩니다.
coverage-run-rustdoc 스위트
tests/coverage-run-rustdoc의 테스트들은 계측된 doctest도 실행하여 커버리지 보고서에 포함합니다. 이렇게 하면 주 coverage 스위트만 실행할 때 rustdoc을 빌드하지 않아도 됩니다.
크래시 테스트
tests/crashes는 컴파일러에서 ICE나 패닉을 일으키거나 다른 방식으로 크래시를 일으킬 것으로 예상되는 테스트 모음으로, 의도치 않은 수정을 추적하는 데 쓰입니다. 이전에는 이 작업을 https://github.com/rust-lang/glacier에서 했지만, rust-lang/rust 테스트 스위트 안에서 하는 편이 더 편리합니다.
이 스위트의 테스트는 반드시 rustc에서 ICE나 패닉을 일으키거나 다른 방식으로 크래시를 일으켜야 합니다. rustc가 1이나 0이 아닌 종료 상태로 종료되면 테스트는 “통과“합니다.
자세한 stdout/stderr를 보려면 다음과 같이 COMPILETEST_VERBOSE_CRASHES=1을 설정해야 합니다.
$ COMPILETEST_VERBOSE_CRASHES=1 ./x test tests/crashes/999999.rs --stage 1
누구나 이슈 트래커에서 “추적되지 않는” 크래시를 추가할 수 있습니다. 여러 이슈의 테스트 케이스를 하나의 PR에 함께 포함하는 것을 강력히 권장합니다. 이때 각 이슈 번호는 파일 이름(12345.rs면 충분합니다)에 기재하고, 파일 안에도 //@ known-bug: #12345 지시문으로 기재해야 합니다. PR이 병합되면 관련 이슈에 S-bug-has-test 레이블을 붙여 주십시오.
크래시 중 하나를 우연히 수정했다면 해당 테스트를 tests/ui의 적절한 하위 디렉터리로 옮기고 의미 있는 이름을 붙여 주십시오. 파일 맨 위에 이 테스트가 존재하는 이유를 설명하는 문서 주석을 추가해 주십시오. 예제가 이전에 어떻게 rustc를 크래시시켰는지, 그리고 이를 수정하기 위해 무엇을 했는지 간단히 설명할 수 있다면 더욱 좋습니다.
다음을
Fixes #NNNNN
Fixes #MMMMM
풀 리퀘스트 설명에 추가하면 병합 시 해당 티켓이 자동으로 닫힙니다.
먼저 수정 사항이 이슈의 일부만이 아니라 근본 원인을 실제로 해결하는지 확인하십시오. 이슈 번호는 파일 이름이나 테스트 파일 안의 //@ known-bug 지시문에서 찾을 수 있습니다.
보조 크레이트 빌드하기
일부 테스트에서는 추가 보조 크레이트를 컴파일해야 하는 경우가 흔합니다. 이를 돕는 여러 지시문이 있습니다:
aux-buildaux-crateaux-binaux-codegen-backendproc-macro
aux-build는 지정한 소스 파일로부터 별도의 크레이트를 빌드합니다. 소스 파일은 테스트 파일 옆의 auxiliary라는 디렉터리에 있어야 합니다.
//@ aux-build: my-helper.rs
extern crate my_helper;
// ... You can use my_helper.
보조 크레이트는 가능하면 dylib로 빌드됩니다(dylib를 지원하지 않는 플랫폼이거나 보조 파일에 no-prefer-dynamic 헤더가 지정된 경우는 제외합니다). 외부 크레이트를 찾는 데에는 -L 플래그가 사용됩니다.
aux-crate는 aux-build와 매우 비슷합니다. 하지만 --extern 플래그를 사용해 외부 크레이트에 링크하여, 해당 크레이트를 extern prelude로 사용할 수 있게 합니다. 이를 통해 의존성 이름 변경과 같은 --extern 플래그의 추가 구문을 지정할 수 있습니다. 예를 들어, //@ aux-crate: foo=bar.rs는 auxiliary/bar.rs를 컴파일하고 테스트 안에서 foo라는 이름으로 사용할 수 있게 합니다. 이는 Cargo가 의존성 이름을 바꾸는 방식과 비슷합니다. --extern 수정자를 지정하는 것도 가능합니다. 예를 들어, //@ aux-crate: noprelude:foo=bar.rs와 같이 지정합니다.
aux-bin은 aux-build와 비슷하지만 라이브러리 대신 바이너리를 빌드합니다. 바이너리는 테스트의 작업 디렉터리를 기준으로 auxiliary/bin에 위치하게 됩니다.
aux-codegen-backend는 aux-build와 비슷하지만, 메인 파일을 빌드할 때 컴파일된 dylib를 -Zcodegen-backend에 전달합니다. 이는 컴파일러 크레이트를 사용해야 하므로 tests/ui-fulldeps에 있는 테스트에서만 동작합니다.
보조 proc-macro
proc-macro 의존성이 필요하다면 proc-macro 지시문을 사용할 수 있습니다. 이 지시문은 aux-build와 똑같이 동작합니다. 즉, proc-macro 테스트 보조 파일을 메인 테스트 파일과 같은 상위 폴더 아래의 auxiliary 폴더에 두어야 합니다. 다만 proc-macro 테스트 보조 파일에는 aux-build와 비교해 네 가지 사전 설정 동작이 추가로 적용됩니다:
- 보조 테스트 파일은
--crate-type proc-macro로 빌드됩니다. - 보조 테스트 파일은
-C prefer-dynamic없이 빌드됩니다. 즉, 보조 크레이트에 대해 dylib를 생성하려고 시도하지 않습니다. - 보조 크레이트는
--extern <aux_crate_name>을 통해 extern prelude로 테스트 파일에서 사용할 수 있게 됩니다. UI 테스트는 기본적으로 2015 에디션을 사용하므로,use임포트에서 보조 크레이트 이름을 사용하려면 메인 테스트 파일이 2018 이상의 에디션을 사용하지 않는 한 여전히extern <aux_crate_name>을 지정해야 한다는 점에 유의하십시오. proc_macro크레이트는 extern prelude 모듈로 제공됩니다.extern proc_macro;에도 2015 에디션과 그 이후 에디션 사이의 구분이 동일하게 적용됩니다.
예를 들어 테스트 tests/ui/cat/meow.rs와 proc-macro 보조 파일 tests/ui/cat/auxiliary/whiskers.rs가 있다고 합시다:
tests/ui/cat/
meow.rs # main test file
auxiliary/whiskers.rs # auxiliary
// tests/ui/cat/meow.rs
//@ proc-macro: whiskers.rs
extern crate whiskers; // needed as ui test defaults to edition 2015
fn main() {
whiskers::identity!();
}
// tests/ui/cat/auxiliary/whiskers.rs
extern crate proc_macro;
use proc_macro::*;
#[proc_macro]
pub fn identity(ts: TokenStream) -> TokenStream {
ts
}
참고:
proc-macro헤더는 현재 rustdoc 테스트에서build-aux-doc헤더와 함께 동작하지 않습니다. 이 경우aux-build헤더를 사용하고, proc-macro 안에서#![crate_type="proc_macro"]와//@ force-host및//@ no-prefer-dynamic헤더를 사용해야 합니다.
리비전
리비전을 사용하면 하나의 테스트 파일을 여러 테스트에 사용할 수 있습니다. 이를 위해 파일 맨 위에 특별한 지시문을 추가합니다:
//@ revisions: foo bar baz
이렇게 하면 테스트가 세 번 컴파일(및 테스트)되며, 한 번은 --cfg foo로, 한 번은 --cfg bar로, 한 번은 --cfg baz로 수행됩니다. 따라서 테스트 안에서 #[cfg(foo)] 등을 사용하여 각 결과를 조정할 수 있습니다.
지시문과 예상 오류 메시지를 특정 리비전에 맞게 지정할 수도 있습니다. 이렇게 하려면 다음과 같이 지시문의 경우 //@ 뒤에, UI 오류 주석의 경우 // 뒤에 [revision-name]을 추가하십시오:
// A flag to pass in only for cfg `foo`:
//@[foo]compile-flags: -Z verbose-internals
#[cfg(foo)]
fn test_foo() {
let x: usize = 32_u32; //[foo]~ ERROR mismatched types
}
//[foo,bar,baz]~^처럼 쉼표로 구분된 목록으로 여러 리비전을 지정할 수 있습니다.
LLVM FileCheck 도구를 사용하는 테스트 스위트에서는 현재 리비전 이름이 FileCheck 지시문의 추가 접두사로도 등록됩니다:
//@ revisions: NORMAL COVERAGE
//@[COVERAGE] compile-flags: -Cinstrument-coverage
//@[COVERAGE] needs-profiler-runtime
// COVERAGE: @__llvm_coverage_mapping
// NORMAL-NOT: @__llvm_coverage_mapping
// CHECK: main
fn main() {}
모든 지시문이 특정 리비전에 맞게 지정되었을 때 의미를 갖는 것은 아니라는 점에 유의하십시오. 예를 들어 ignore-test 지시문(및 모든 “ignore” 지시문)은 현재 특정 리비전이 아니라 테스트 전체에만 적용됩니다. 리비전에 맞게 지정했을 때 실제로 동작하도록 의도된 지시문은 오류 패턴과 컴파일러 플래그뿐입니다.
다음 테스트 스위트는 리비전을 지원하지 않는다는 점에 유의하십시오:
codegen-unitsrun-makerustdoc-htmlrustdoc-json
사용하지 않는 리비전 이름 무시하기
일반적으로 다른 지시문과 오류 주석에서 언급된 리비전 이름은 revisions 지시문에 선언된 실제 리비전과 일치해야 합니다. 이는 ./x test tidy 검사로 강제됩니다.
어떤 이유로 리비전 목록에서 리비전 이름을 일시적으로 제거해야 한다면, 대신 해당 리비전 이름을 //@ unused-revision-names: 헤더에 추가하여 위 검사를 억제할 수 있습니다.
사용하지 않는 이름으로 *를 지정하면(즉, //@ unused-revision-names: *) 사용하지 않는 어떤 리비전 이름이든 언급할 수 있습니다.
비교 모드
Compiletest는 비교 모드라고 하는 여러 모드로 실행할 수 있으며, 이를 사용하여 서로 다른 컴파일러 플래그를 활성화했을 때 모든 테스트의 동작을 비교할 수 있습니다. 이를 통해 특정 플래그에서 어떤 차이가 나타날 수 있는지 드러내고, 발생할 수 있는 문제를 확인할 수 있습니다.
다른 모드로 테스트를 실행하려면 --compare-mode CLI 플래그를 전달해야 합니다:
./x test tests/ui --compare-mode=next-solver
사용할 수 있는 비교 모드는 다음과 같습니다:
polonius—-Zpolonius=next를 사용하여 Polonius로 실행합니다.next-solver—-Znext-solver를 사용하여 차세대 트레이트 솔버로 실행합니다.next-solver-coherence—-Znext-solver=coherence를 사용하여 차세대 트레이트 솔버로 일관성(coherence) 검사를 실행합니다.split-dwarf—-Csplit-debuginfo=unpacked를 사용하여 패킹되지 않은 split-DWARF로 실행합니다.split-dwarf-single—-Csplit-debuginfo=packed를 사용하여 패킹된 split-DWARF로 실행합니다.
UI 테스트가 모드마다 다른 출력을 지원하는 방식에 대한 자세한 내용은 UI 비교 모드를 참조하십시오.
CI에서 비교 모드는 하나의 Linux 빌더에서만, 그리고 다음 설정으로만 사용됩니다.
tests/debuginfo:split-dwarf모드를 사용합니다. 이를 통해 split-DWARF를 활성화했을 때 어떤 debuginfo 테스트도 영향을 받지 않도록 보장할 수 있습니다.
비교 모드는 리비전과 별개라는 점에 유의하십시오. ./x test tests/ui를 실행하면 모든 리비전이 테스트되지만, 비교 모드는 --compare-mode 플래그를 통해 각각 수동으로 실행해야 합니다.
병렬 프런트엔드
Compiletest를 --parallel-frontend-threads 플래그와 함께 실행하면 컴파일러를 병렬 모드로 실행할 수 있습니다. 이를 사용하여 컴파일러가 병렬 모드에서도 비병렬 모드와 동일한 출력을 생성하는지 확인하고, 병렬 모드에서 발생할 수 있는 문제를 점검할 수 있습니다.
병렬 모드로 테스트를 실행하려면 --parallel-frontend-threads CLI 플래그를 전달해야 합니다.
./x test tests/ui -- --parallel-frontend-threads=N --iteration-count=M
여기서 N은 병렬 프런트엔드에 사용할 스레드 수이고, M은 (비결정성을 포착할 가능성을 높이기 위해) 병렬 모드에서 각 테스트를 실행할 횟수입니다.
또한 --parallel-frontend-threads로 실행할 때는 병렬 프런트엔드의 출력이 줄 순서 측면에서 비결정적일 수 있으므로, 모든 테스트에 compare-output-by-lines 지시문이 암묵적으로 적용됩니다.
병렬 프런트엔드는 현재 UI 테스트에서만 사용할 수 있으며, 다른 테스트 스위트에서는 아직 지원되지 않습니다.
UI 테스트
UI 테스트는 compiletest의 특정 테스트 스위트입니다.
소개
tests/ui에 있는 테스트는 범용 테스트 모음으로, 주로 컴파일러의 콘솔 출력을 검증하는 데 초점을 맞추지만 그 밖의 다양한 용도로도 사용할 수 있습니다. 예를 들어, 테스트가 결과 프로그램을 실행하여 그 동작을 검증하도록 구성할 수도 있습니다.
tests/ui 아래 각 하위 디렉터리의 용도에 대한 개요는 README.md를 참조하십시오. 이는 새 테스트를 작성하면서 테스트를 배치할 범주를 찾고 있을 때 유용합니다.
#![no_std] 크로스 컴파일 테스트를 다루어야 한다면 minicore 테스트 보조 도구 장을 참조하십시오.
테스트의 일반적인 구조
테스트는 tests/ui 디렉터리에 위치한 Rust 소스 파일로 구성됩니다. 테스트는 목적과 테스트 범주에 따라 적절한 하위 디렉터리에 배치해야 합니다. 테스트를 tests/ui에 직접 배치하는 것은 허용되지 않습니다.
compiletest는 rustc를 사용하여 테스트를 컴파일하고, 그 출력을 테스트 옆에 위치한 .stdout 또는 .stderr 파일에 저장된 예상 출력과 비교합니다. 자세한 내용은 출력 비교를 참조하십시오.
또한 오류와 경고는 소스 파일 안에 주석으로 표시해야 합니다. 자세한 내용은 오류 주석을 참조하십시오.
//@ 접두사가 붙은 특수 주석 형태의 compiletest 지시문은 테스트를 컴파일하는 방식과 예상되는 동작을 제어합니다.
대부분의 테스트가 컴파일러 오류를 테스트하므로, 테스트는 컴파일에 실패할 것으로 예상됩니다. 지시문으로 이 동작을 변경할 수 있습니다. 통과/실패 예상 제어하기를 참조하십시오.
기본적으로 테스트는 실행 가능한 바이너리로 빌드됩니다. 다른 크레이트 타입이 필요하다면 #![crate_type] 속성을 사용하여 필요에 맞게 설정할 수 있습니다.
출력 비교
UI 테스트는 컴파일러의 예상 출력을 테스트 옆의 .stderr 및 .stdout 스냅숏에 저장합니다. 일반적으로 이 파일들은 --bless CLI 옵션으로 생성한 다음, 직접 살펴보며 예상한 내용이 들어 있는지 확인합니다.
출력은 원치 않는 차이를 무시하도록 정규화됩니다. 정규화 절을 참조하십시오. 파일이 없으면 compiletest는 해당 출력이 비어 있을 것으로 예상합니다.
정규화, 리비전 및 이어지는 다른 도구 대부분을 사용하는 흔한 이유는 플랫폼 간 차이를 고려하기 위해서입니다. 이러한 도구 대신 다른 방법을 고려하십시오. 예를 들어, 크로스 컴파일을 사용하도록 테스트를 고쳐 무효일 가능성이 있는 모든 ABI를 테스트하는 대신, 모든 플랫폼에서 무효인 extern "rust-invalid" ABI를 사용하는 방법이 있습니다.
stdout/stderr 파일은 여러 개일 수 있습니다. 일반적인 형식은 다음과 같습니다.
*test-name*`.`*revision*`.`*compare_mode*`.`*extension*
- test-name에는 점이 포함될 수 없습니다. 테스트 이름에는 점이 들어갈 수 없습니다. 이는 테스트 출력 파일 이름의 일반 형식을 예측 가능하게 만들어, 떠도는 테스트 출력 파일을 추적하기 위해 패턴 매칭을 할 수 있도록 하기 위함입니다.
- revision은 리비전 이름입니다. 리비전을 사용하지 않을 때는 포함되지 않습니다.
- compare_mode는 비교 모드입니다. 이 파일은 해당 비교 모드가 활성화된 경우에만 검사됩니다. 파일이 존재하지 않으면 compiletest는 비교 모드가 없는 파일을 검사합니다.
- extension은 검사 대상 출력의 종류입니다.
stderr— 컴파일러 stderrstdout— 컴파일러 stdoutrun.stderr— 테스트 실행 시의 stderrrun.stdout— 테스트 실행 시의 stdout64bit.stderr— 64비트 타깃에서stderr-per-bitwidth지시문을 사용할 때의 컴파일러 stderr32bit.stderr— 32비트 타깃에서stderr-per-bitwidth지시문을 사용할 때의 컴파일러 stderr
간단한 예로는 foo.rs 테스트 옆에 있는 foo.stderr가 있습니다. 더 복잡한 예로는 foo.my-revision.polonius.stderr가 있습니다.
compiletest가 출력 파일을 검사하는 방식을 바꾸는 지시문이 몇 가지 있습니다.
stderr-per-bitwidth— 타깃 포인터 너비에 따라 별도의 출력 파일을 검사합니다. 대신normalize-stderr지시문을 사용하는 것을 고려하십시오(정규화 참조).dont-check-compiler-stderr— 컴파일러의 stderr를 무시합니다.dont-check-compiler-stdout— 컴파일러의 stdout을 무시합니다.compare-output-by-lines— 일부 테스트는 출력 순서가 비결정적이므로 줄 단위로 비교해야 합니다.
UI 테스트는 -Zdeduplicate-diagnostics=no 플래그와 함께 실행되며, 이 플래그는 rustc에 내장된 진단 중복 제거 메커니즘을 비활성화합니다. 따라서 출력에서 일부 중복된 메시지를 볼 수 있습니다. 이를 통해 중복된 진단이 생성되는 상황을 파악하기 쉬워집니다.
정규화
컴파일러 출력은 플랫폼 간의 출력 차이, 주로 파일 이름과 관련된 차이를 없애기 위해 정규화됩니다.
compiletest는 컴파일러 출력에 대해 다음과 같은 치환을 수행합니다.
- 테스트가 정의된 디렉터리는
$DIR로 치환됩니다. 예:/path/to/rust/tests/ui/error-codes - 표준 라이브러리 소스 디렉터리는
$SRC_DIR로 치환됩니다. 예:/path/to/rust/library $SRC_DIR안의 경로에 대한 줄 번호와 열 번호는LL:COL로 치환됩니다. 이렇게 하면 표준 라이브러리의 구조가 바뀌더라도.stderr파일이 광범위하게 변경되지 않도록 하는 데 도움이 됩니다. 예:$SRC_DIR/alloc/src/sync.rs:53:46- 테스트 출력이 저장되는 기본 디렉터리는
$TEST_BUILD_DIR로 대체됩니다. 이는 몇몇 드문 경우에만 나타납니다. 예시:/path/to/rust/build/x86_64-unknown-linux-gnu/test/ui - 표준 라이브러리 소스의 실제 디렉터리는
$SRC_DIR_REAL로 대체됩니다. - 컴파일러 소스의 실제 디렉터리는
$COMPILER_DIR_REAL로 대체됩니다. - 탭은
\t로 대체됩니다. - 경로 안의 역슬래시(
\)는 (휴리스틱을 사용하여) 슬래시(/)로 변환됩니다. 이는 Windows 스타일 경로와의 차이를 정규화하는 데 도움이 됩니다. - CRLF 줄바꿈은 LF로 변환됩니다.
//~ ERROR some message와 같은 오류 줄 주석은 제거됩니다.- 다양한 v0 및 레거시 심볼 해시는
[HASH]나<SYMBOL_HASH>와 같은 자리 표시자로 대체됩니다.
또한 컴파일러는 -Z ui-testing 플래그와 함께 실행되며, 이 플래그를 사용하면 컴파일러가 직접 진단 출력을 일부 변경하여 UI 테스트에 더 적합하게 만듭니다.
예를 들어, 출력에서 줄 번호를 익명화합니다(각 소스 줄 앞에 붙는 줄 번호가 LL로 대체됩니다). 극히 드문 경우에는 //@ compile-flags: -Z ui-testing=no 지시문으로 이 모드를 비활성화할 수 있습니다.
-Z ui-testing=no를 사용할 때는 UI 테스트 스위트를 실행하는 터미널의 너비에 따라 테스트가 실패하거나 통과하는 일이 없도록 --diagnostic-width 인자도 함께 설정해야 합니다.
참고: 테스트를 가리키는 --> 줄의 줄 번호와 열 번호는 정규화되지 않으며 있는 그대로 남습니다. 이렇게 하면 컴파일러가 계속 올바른 위치를 가리키는지 확인할 수 있고, stderr 파일도 읽기 쉽게 유지됩니다. 이상적으로는 모든 줄/열 정보를 유지하는 것이 좋겠지만, 그렇게 하면 소스를 조금만 변경해도 diff가 커지고 병합 충돌과 테스트 오류가 더 자주 발생합니다.
때로는 이러한 내장 정규화만으로는 충분하지 않습니다. 이런 경우에는 normalize-* 지시문을 사용하여 사용자 정의 정규화 규칙을 제공할 수 있습니다. 예를 들면 다음과 같습니다.
//@ normalize-stdout: "foo" -> "bar"
//@ normalize-stderr: "foo" -> "bar"
//@ normalize-stderr-32bit: "fn\(\) \(32 bits\)" -> "fn\(\) \($$PTR bits\)"
//@ normalize-stderr-64bit: "fn\(\) \(64 bits\)" -> "fn\(\) \($$PTR bits\)"
이는 32비트 플랫폼에서 컴파일러가 stderr에 fn() (32 bits)를 쓸 때마다 이를 fn() ($PTR bits)로 정규화해야 한다고 테스트에 알려 줍니다. 64비트의 경우도 마찬가지입니다. 대체는 regex 크레이트가 제공하는 기본 정규식 문법을 사용하는 정규식으로 수행됩니다.
해당 참조 파일은 정규화된 출력을 사용하여 32비트와 64비트 플랫폼을 모두 테스트합니다.
...
|
= note: source type: fn() ($PTR bits)
= note: target type: u16 (16 bits)
...
구체적인 사용 예시는 ui/transmute/main.rs와 main.stderr를 참고하십시오.
오류 주석
오류 주석은 컴파일러가 내보낼 것으로 예상되는 오류를 지정합니다. 오류 주석은 소스에서 오류가 위치한 줄에 “붙어” 있습니다.
fn main() {
boom //~ ERROR cannot find value `boom` in this scope [E0425]
}
UI 테스트에는 컴파일러 출력 전체를 담은 .stderr 파일이 있지만, UI 테스트에서는 오류를 소스 안에도 주석으로 표시해야 합니다. .stderr 파일은 보통 자동으로 생성되므로, 이렇게 중복해 두면 실수를 방지하는 데 도움이 됩니다. 또한 .stderr 파일을 소스와 비교할 필요 없이 파일 하나만 보고도 오류 범위가 어디를 가리켜야 하는지 바로 확인할 수 있습니다. 마지막으로, 오류 주석은 예상하지 못한 추가 오류가 발생하지 않도록 보장합니다.
이러한 주석은 여러 형태가 있지만, 일반적으로 진단 수준(예: ERROR)과 예상되는 오류 출력의 부분 문자열로 이루어진 주석입니다. 메시지 전체를 적을 필요는 없지만, 주석만 보고도 의미를 알 수 있도록 메시지의 중요한 부분은 반드시 포함하십시오.
대부분의 오류 주석은 진단이 발생한 줄과 일치해야 합니다. 메시지를 줄과 대응시키는 방법은 여러 가지가 있습니다(아래 예시를 참고하십시오).
~: 오류 수준과 메시지를 현재 줄에 대응시킵니다.~^: 오류 수준과 메시지를 오류 주석 줄의 이전 줄에 대응시킵니다. 캐럿(^)을 하나 추가할 때마다 한 줄씩 더 위를 가리키므로,~^^^는 오류 주석 줄에서 세 줄 위를 가리킵니다.~|: 오류 수준과 메시지를 이전 주석과 같은 줄에 대응시킵니다. 같은 줄에 여러 메시지가 대응될 때는 캐럿을 여러 개 쓰는 것보다 이 방법이 더 편리합니다.~v: 오류 수준과 메시지를 오류 주석 줄의 다음 줄에 대응시킵니다. 기호(v)를 하나 추가할 때마다 한 줄씩 더 아래를 가리키므로,~vvv는 오류 주석 줄에서 세 줄 아래를 가리킵니다.
예시:
let _ = same_line; //~ ERROR undeclared variable
fn meow(_: [u8]) {}
//~^ ERROR unsized
//~| ERROR anonymous parameters
//~(또는 다른 변형)와 그 뒤에 오는 텍스트 사이의 공백 문자는 무시해도 됩니다(즉, //~ ERROR와 //~ERROR 사이에는 의미상 차이가 없지만, 코드베이스에서는 전자가 더 흔하게 쓰입니다).
~? <diagnostic kind>(예: ~? ERROR)는 줄 정보가 아예 없는 진단이나, 줄 정보가 주 테스트 파일1 밖에 있는 진단을 매칭하는 데 사용합니다. 이러한 주석은 테스트 파일의 어느 줄에나 둘 수 있습니다.
오류 주석 예시
다음은 UI 테스트 소스의 여러 줄에 오류 주석을 다는 예시입니다.
오류 줄에 위치하는 경우
//~ ERROR 관용구를 사용하십시오.
fn main() {
let x = (1, 2, 3);
match x {
(_a, _x @ ..) => {} //~ ERROR `_x @` is not allowed in a tuple
_ => {}
}
}
오류 줄 아래에 위치하는 경우
//~^ 관용구를 사용하되, 문자열에 넣는 캐럿의 개수로 몇 줄 위인지를 나타내십시오. 아래 예시에서는 오류 줄이 오류 주석 줄보다 네 줄 위에 있으므로 주석에 캐럿이 네 개 들어 있습니다.
fn main() {
let x = (1, 2, 3);
match x {
(_a, _x @ ..) => {} // <- the error is on this line
_ => {}
}
}
//~^^^^ ERROR `_x @` is not allowed in a tuple
위쪽 오류 주석 줄에서 정한 것과 같은 오류 줄을 사용하는 경우
//~| 관용구를 사용하여 위쪽 오류 주석 줄과 같은 오류 줄을 지정하십시오.
struct Binder(i32, i32, i32);
fn main() {
let x = Binder(1, 2, 3);
match x {
Binder(_a, _x @ ..) => {} // <- the error is on this line
_ => {}
}
}
//~^^^^ ERROR `_x @` is not allowed in a tuple struct
//~| ERROR this pattern has 1 field, but the corresponding tuple struct has 3 fields [E0023]
오류 줄 위에 위치하는 경우
//~v 관용구를 사용하되, 문자열에 넣는 v의 개수로 몇 줄 아래인지를 나타내십시오. 이 방법은 주로 파일 끝에서 발생하는 닫히지 않은 구분자나 닫히지 않은 리터럴 같은 오류를 매칭하는 렉서 또는 파서 테스트에서 사용합니다.
// ignore-tidy-trailing-newlines
//~v ERROR this file contains an unclosed delimiter
fn main((ؼ
줄 정보가 없는 오류
줄 정보가 없는 오류를 매칭하려면 //~?를 사용하십시오. //~?는 정확하게 동작하며, 줄 정보가 있는 오류와는 매칭되지 않습니다. //@ error-pattern은 정확하지 않고 모든 오류를 확인하지도 않으므로, 컴파일러 진단을 매칭하려는 테스트에서는 //@ error-pattern보다 이 방법을 우선 사용하십시오.
//@ compile-flags: --print yyyy
//~? ERROR unknown print request: `yyyy`
error-pattern
error-pattern 지시문은 특정 스팬이 없는 런타임 메시지에 사용할 수 있으며, 예외적인 경우에는 컴파일 타임 메시지에도 사용할 수 있습니다.
다음 테스트를 생각해 봅시다.
fn main() {
let a: *const [_] = &[1, 2, 3];
unsafe {
let _b = (*a)[3];
}
}
이 테스트가 “index out of bounds“를 출력하는지 확인하려고 하지만, 런타임 오류에는 스팬이 없으므로 ERROR 애너테이션을 사용할 수 없습니다. 이럴 때 error-pattern 지시문을 사용합니다.
//@ error-pattern: index out of bounds
fn main() {
let a: *const [_] = &[1, 2, 3];
unsafe {
let _b = (*a)[3];
}
}
컴파일 타임 출력을 엄격하게 테스트하려면 스팬이 없는 진단을 위한 //~? 애너테이션을 포함하여 줄 애너테이션 //~를 최대한 사용하십시오.
컴파일 타임 출력이 타깃에 따라 달라지거나 너무 장황하다면, //@ dont-require-annotations: <diagnostic-kind> 지시문을 사용하여 줄 애너테이션 검사가 모든 진단을 요구하지 않도록 하십시오. 이 모드에서는 일부 컴파일러 메시지가 애너테이션으로 다루어지지 않은 채로 남을 수 있습니다.
런타임 출력을 검사할 때는 //@ check-run-results가 더 나을 수 있습니다.
런타임 패닉 출력에서 특정 문자열 패턴을 찾는 경우처럼 위의 방법이 모두 통하지 않을 때에만 error-pattern을 사용하십시오.
줄 애너테이션 //~와 error-pattern은 서로 호환되며 같은 테스트에서 함께 사용할 수 있습니다.
진단 종류(오류 수준)
사용할 수 있는 진단 종류는 다음과 같습니다.
ERRORWARN(또는WARNING)NOTEHELPSUGGESTIONRAW
SUGGESTION 종류는 진단 제안에서 기대하는 대체 텍스트가 무엇인지 지정하는 데 사용합니다. RAW 종류는 컴파일러가 구조화된 json 대신 또는 그에 더해 때때로 내보내는 비구조화 출력의 줄을 매칭하는 데 사용할 수 있습니다.
기본적으로 ERROR와 WARN 종류는 줄 애너테이션 //~로 빠짐없이 다루어야 합니다.
그 밖의 종류는 테스트 파일에 해당 종류의 애너테이션이 하나 이상 있을 때에만 줄 애너테이션을 달아야 합니다. 예를 들어 //~ NOTE를 하나 작성하면 파일 안의 다른 모든 //~ NOTE도 명시적으로 작성해야 합니다.
빠짐없는 애너테이션 요구를 해제하려면 //@ dont-require-annotations 지시문을 사용하십시오. 예를 들어 노트에 선택적으로 애너테이션을 달려면 //@ dont-require-annotations: NOTE를 사용하십시오. 타깃에 따라 달라지는 컴파일러 출력처럼 중대한 이유가 없는 한 ERROR와 WARN에는 이 지시문을 사용하지 마십시오.
일부 진단은 종류나 지시문과 관계없이 줄 애너테이션을 달 필요가 전혀 없습니다. 여러 줄 진단의 두 번째 이후 줄이나, aborting due to N previous errors처럼 어디에나 나타나는 진단이 그 예입니다.
미사용 경고는 대개 테스트의 초점이 아니므로, UI 테스트는 기본적으로 -A unused 플래그를 사용하여 모든 미사용 경고를 무시합니다. 하지만 간단한 코드 예제에서는 미사용 경고가 자주 발생합니다. 테스트가 특별히 미사용 경고를 테스트하는 것이라면 필요에 따라 적절한 #![warn(unused)] 속성을 추가하기만 하면 됩니다.
cfg 리비전
리비전을 사용할 때는 현재 리비전에 따라 서로 다른 메시지를 조건부로 검사할 수 있습니다. 다음과 같이 리비전 cfg 이름을 대괄호 안에 넣으면 됩니다:
//@ edition:2018
//@ revisions: mir thir
//@[thir] compile-flags: -Z thir-unsafeck
async unsafe fn f() {}
async fn g() {
f(); //~ ERROR call to unsafe function is unsafe
}
fn main() {
f(); //[mir]~ ERROR call to unsafe function is unsafe
}
이 예제에서 두 번째 오류 메시지는 mir 리비전에서만 출력됩니다. thir 리비전은 첫 번째 오류만 출력합니다.
cfg로 인해 컴파일러가 서로 다른 출력을 내보내는 경우, 테스트는 각 출력에 대해 여러 개의 .stderr 파일을 가질 수 있습니다. 위 예제에서는 각 리비전의 서로 다른 출력을 담은 .mir.stderr 파일과 .thir.stderr 파일이 존재하게 됩니다.
참고: cfg 리비전은
#[cfg]속성을 사용하여 소스 코드 내부에서도 동작합니다.관례상 항상 거짓인 설정이 필요할 때는
FALSEcfg를 사용합니다.
통과/실패 기대 결과 제어하기
대부분의 테스트는 잘못된 입력과 오류 진단을 검사하기 때문에, 기본적으로 UI 테스트는 컴파일 오류를 발생시킬 것으로 기대됩니다. 하지만 컴파일이 성공할 것으로 기대되는 UI 테스트를 만들 수도 있으며, 그 결과로 생성된 프로그램을 실행할 수도 있습니다. 다음 지시문 중 하나를 추가하기만 하면 됩니다:
- 통과 지시문:
//@ check-pass— 컴파일은 성공해야 하지만 코드 생성은 건너뜁니다(코드 생성은 비용이 크고 대부분의 경우 실패하지 않아야 합니다).//@ build-pass— 컴파일과 링크는 성공해야 하지만 결과 바이너리는 실행하지 않습니다.//@ run-pass— 컴파일이 성공해야 하며, 결과 바이너리를 실행하면 성공을 나타내는 종료 코드 0으로 종료되어야 합니다.
- 실패 지시문:
//@ check-fail— 컴파일이 실패해야 합니다(코드 생성 단계는 건너뜁니다). 이것이 UI 테스트의 기본값입니다.//@ build-fail— 코드 생성 단계에서 컴파일이 실패해야 합니다. 이 경우rustc를 두 번 실행합니다:- 첫 번째 실행은 코드 생성 단계 없이 컴파일이 성공하는지 확인하기 위한 것입니다
- 두 번째 실행은 전체 컴파일이 실패하는지 확인하기 위한 것입니다
//@ run-fail— 컴파일은 성공해야 하지만, 결과 바이너리를 실행하면 일반적인 실패를 나타내는1..=127범위의 코드로 종료되어야 합니다. 언와인드를 지원하지 않는 타깃에서는 크래시도 허용됩니다.//@ run-crash— 컴파일은 성공해야 하지만, 결과 바이너리를 실행하면 크래시로 실패해야 합니다. 크래시는 “0..=127범위의 코드로 종료되지 않는 것“으로 정의됩니다.- Linux에서의 예:
SIGABRT또는SIGSEGV에 의한 종료입니다. - Windows에서의 예:
STATUS_ILLEGAL_INSTRUCTION(0xC000001D)에 해당하는 코드로 종료되는 것입니다.
- Linux에서의 예:
//@ run-fail-or-crash— 컴파일은 성공해야 하지만, 결과 바이너리를 실행하면run-fail또는run-crash중 하나가 되어야 합니다. 테스트가 일부 타깃에서는 크래시하지만 다른 타깃에서는 단순히 실패하는 경우에 유용합니다.
run-pass, run-fail, run-crash, run-fail-or-crash 테스트에서는 기본적으로 프로그램 자체의 출력을 검사하지 않습니다.
프로그램을 실행한 출력을 검사하려면 check-run-results 지시문을 포함하십시오. 이 지시어는 .run.stderr 및 .run.stdout 파일을 찾아 프로그램의 실제 출력과 비교합니다.
*-pass 지시문이 있는 테스트는 --pass 명령줄 옵션으로 재정의할 수 있습니다.
./x test tests/ui --pass check
--pass 옵션은 UI 테스트에만 영향을 줍니다. --pass check를 사용하면 UI 테스트 스위트를 훨씬 빠르게(제 시스템에서는 대략 두 배 빠르게) 실행할 수 있지만, 당연히 그만큼 검사하는 범위는 줄어듭니다.
테스트가 --pass 재정의로는 제대로 동작하지 않는 경우 no-pass-override 지시문을 사용하여 --pass CLI 플래그를 무시할 수 있습니다.
알려진 버그
known-bug 지시문은 아직 수정되지 않은 알려진 버그를 보여 주는 테스트에 사용할 수 있습니다. 알려진 버그에 대한 테스트를 추가하면 다음을 비롯한 여러 가지 이유로 도움이 됩니다:
- 버그가 수정되었을 때 편리하게 재사용할 수 있는, 동작하는 테스트를 유지할 수 있습니다.
- 버그가 우연히 수정되면 실패하는 감시 장치 역할을 합니다. 이를 통해 개발자는 관련 이슈가 수정되었으며 닫을 수도 있다는 사실을 알 수 있습니다.
이 지시문은 쉼표로 구분된 이슈 번호 또는 "unknown"을 인자로 받습니다.
//@ known-bug: #123, #456(이슈가 rust-lang/rust에 있는 경우)//@ known-bug: rust-lang/chalk#123456(#앞에 임의의 텍스트를 허용하므로, 이슈가 다른 저장소에 있을 때 유용합니다)//@ known-bug: unknown(아직 알려진 이슈가 없는 경우이며, 이슈가 아직 없다면 새로 여는 것이 좋습니다)
known-bug가 있는 테스트에는 오류 주석을 포함하지 마십시오. 이러한 테스트에도 다른 일반 지시문과 stdout/stderr 파일은 포함해야 합니다.
테스트 구성
테스트 파일을 어디에 둘지 정할 때는 검사하려는 대상과 가장 잘 맞는 하위 디렉터리를 찾아보십시오. 최대한 체계적으로 정리된 상태를 유지하십시오. 물론 일부 테스트는 여러 범주에 걸쳐 있을 수 있고 기존 구조가 잘 맞지 않을 수도 있으므로 쉽지 않을 수 있습니다.
테스트가 무엇을 검사하는지 간결하게 설명하는 이름을 테스트에 붙이십시오. 테스트 이름에 이슈 번호를 넣지 마십시오. 이에 대한 더 자세한 논의는 모범 사례를 참고하십시오.
이상적으로는 여기서 어떤 코드 부분을 테스트하는지 알아보기 쉬운 디렉터리에 테스트를 추가해야 합니다(예: tests/ui/borrowck/reject-move-out-of-borrow-via-pat.rs).
새 기능을 작성할 때는 테스트를 보관할 하위 디렉터리를 만드는 것이 좋을 수 있습니다. 예를 들어 RFC 1234(“Widgets”)를 구현하고 있다면 tests/ui/rfc1234-widgets/와 같은 디렉터리에 테스트를 두는 것이 합리적일 수 있습니다.
그 밖의 경우에는 이미 적합한 디렉터리가 있을 수 있습니다.
시간이 지나면서 tests/ui 디렉터리는 매우 빠르게 커졌습니다. tidy에는 어떤 하위 디렉터리도 항목이 1000개를 넘지 않도록 보장하는 검사가 있습니다. 파일이 너무 많으면 편집기/IDE에서 다루기 불편하고 GitHub UI가 1000개를 초과하는 항목을 표시하지 않기 때문에 문제가 됩니다. 그러나 tests/ui(UI 테스트 루트 디렉터리)와 tests/ui/issues 디렉터리는 이미 항목이 1000개를 넘기 때문에 이 디렉터리들에는 다른 한도를 설정하였습니다. 따라서 새 테스트를 그곳에 넣지 말고 더 적절한 위치를 찾으십시오.
예를 들어 테스트가 클로저와 관련되어 있다면 tests/ui/closures에 넣어야 합니다. 한도에 도달하면 여기를 조정하여 한도를 늘릴 수 있습니다.
Rustfix 테스트
UI 테스트는 진단 제안이 올바르게 적용되는지, 그리고 그 결과로 변경된 코드가 올바르게 컴파일되는지 검증할 수 있습니다. 이는 run-rustfix 지시문으로 할 수 있습니다.
//@ run-rustfix
//@ check-pass
#![crate_type = "lib"]
pub struct not_camel_case {}
//~^ WARN `not_camel_case` should have an upper camel case name
//~| HELP convert the identifier to upper camel case
//~| SUGGESTION NotCamelCase
Rustfix 테스트에는 제안이 적용된 후의 소스 파일을 담은 .fixed 확장자 파일이 있어야 합니다.
- 테스트가 실행되면 compiletest는 먼저 올바른 린트/경고가 생성되는지 확인합니다.
- 그런 다음 제안을 적용하고
.fixed와 비교합니다(둘은 일치해야 합니다). - 마지막으로 수정된 소스를 컴파일하며, 이 컴파일은 반드시 성공해야 합니다.
보통 rustfix 테스트를 만들 때는 x test --bless 옵션으로 .fixed 파일을 자동으로 생성합니다.
run-rustfix 지시문을 사용하면 MachineApplicable이 아닌 제안까지 포함하여 모든 제안이 적용됩니다. 이것이 문제가 된다면 run-rustfix에 더해 rustfix-only-machine-applicable 지시문을 추가할 수 있습니다. 이 지시어는 여러 제안 수준이 섞여 있고, 기계 적용이 불가능한 제안 중 일부가 깔끔하게 적용되지 않는 경우에 사용해야 합니다.
비교 모드
비교 모드를 사용하면 평소 컴파일할 때와 다른 플래그로 모든 테스트를 실행할 수 있습니다. 경우에 따라 이로 인해 컴파일러의 출력이 달라질 수 있습니다. 이를 지원하기 위해 비교 모드에 따른 출력을 담은 별도의 출력 파일을 저장할 수 있습니다.
예를 들어 Polonius 모드를 사용할 때 테스트 foo.rs는 먼저 foo.polonius.stderr에서 예상 출력을 찾고, 찾지 못하면 일반적인 foo.stderr를 사용합니다. 모드에 따라 진단과 동작이 달라지는 경우가 있으므로 이 기능은 유용합니다. 이를 통해 어떤 테스트가 모드 간에 차이가 있는지 추적하고, 그 진단 차이를 눈으로 직접 확인할 수 있습니다.
드물게 동작이 다른 테스트를 발견한 경우, 다음과 같은 명령을 실행하여 대체 stderr 파일을 생성할 수 있습니다:
./x test tests/ui --compare-mode=polonius --bless
현재 UI 테스트에 대해서는 CI에서 어떤 비교 모드도 검사하지 않습니다.
rustc_* 테스트 속성
컴파일러는 내부 기능 rustc_attrs 뒤에 게이트된, 영구적으로 불안정한 #[rustc_*] 속성 몇 가지를 정의하며, 이 속성들은 컴파일러 내부 정보를 추가로 출력합니다. 자세한 내용은 컴파일러 디버깅의 해당 하위 절을 참고하십시오.
이러한 기능은 테스트에서 컴파일러 내부 상태를 더 정밀하고, 읽기 쉽고, 간편하게 테스트하는 데 사용할 수 있으며, 이는 “사용자에게 노출되는” Rust만으로는 같은 작업을 하기가 매우 어려운 경우에 유용합니다. 실제로 이는 “UI”(사용자 인터페이스)라는 용어를 다소 남용하는 것이며, 이러한 UI 테스트를 블랙박스 테스트에서 화이트박스 테스트로 바꾸는 것이라고 말할 수도 있습니다. 신중하게, 그리고 꼭 필요한 경우에만 사용하십시오.
UI 테스트 모드의 사전 설정 린트 수준
기본적으로 UI 테스트 모드의 테스트 스위트(tests/ui, tests/ui-fulldeps이며, tests/rustdoc-ui는 제외)는 다음을 지정합니다.
-A unused-W unused_attributes(이 린트는 ui 테스트에서 흥미로운 경우가 많기 때문입니다)-A internal_features-A incomplete_features-A unused_parens-A unused_braces
자세한 내용은 runtest를 참고하십시오.
조건은 다음과 같습니다.
- 해당 UI 테스트의 통과 모드는
run보다 아래 단계(즉, 체크 또는 빌드)입니다. - 비교 모드가 지정되지 않은 경우입니다.
이러한 린트는 ui 테스트에서 출력이 매우 많아질 수 있기 때문입니다.
필요에 따라 compile-flags 린트 수준 플래그나 소스 내 린트 수준 속성으로 이를 재정의할 수 있습니다.
rustfix 버전에는 -A unused가 전달되지 않는다는 점에 유의하십시오. 따라서 rustfix가 적용된 파일에서 unused 린트를 억제하려면 #[allow(unused)]를 사용해야 할 수도 있습니다(unused 린트 자체에 대해 rustfix를 테스트하는 경우가 있을 수 있기 때문입니다).
-
이는 보조(aux) 파일이나 우리가 제어할 수 없는 소스와 구별되는,
~?주석이 들어 있는 파일을 말합니다. ↩
Compiletest 지시문
지시문은 compiletest에 테스트를 빌드하고 해석하는 방법을 알려 주는 특수한 주석입니다. 지시문은 rmake.rs run-make 테스트에도 나타날 수 있습니다.
지시문은 보통 이 테스트의 목적을 설명하는 짧은 주석 뒤에 둡니다. Compiletest 테스트 스위트는 //@를 사용하여 주석이 지시문임을 나타냅니다. 예를 들어, 이 테스트는 //@ compile-flags 명령을 사용하여 테스트를 컴파일할 때 rustc에 전달할 사용자 지정 플래그를 지정합니다:
// Test the behavior of `0 - 1` when overflow checks are disabled.
//@ compile-flags: -C overflow-checks=off
fn main() {
let x = 0 - 1;
...
}
지시문은 (//@ run-pass처럼) 단독으로 쓰일 수도 있고 (//@ compile-flags: -C overflow-checks=off처럼) 값을 받을 수도 있습니다.
지시문은 한 줄에 하나씩 작성합니다. 같은 줄에 여러 지시문을 작성할 수 없습니다. 예를 들어, //@ only-x86 only-windows라고 작성하면 only-windows는 별도의 지시문이 아니라 주석으로 해석됩니다.
Compiletest 지시문 목록
다음은 compiletest 지시문 목록입니다. 해당 명령을 더 자세히 설명하는 섹션이 있으면 지시문에 그 섹션으로 가는 링크가 걸려 있습니다. 이 목록은 완전하지 않을 수 있습니다. 지시문은 일반적으로 compiletest 소스의 directives.rs에 있는 TestProps 구조체를 살펴보면 찾을 수 있습니다.
어셈블리
| 지시문 | 설명 | 지원되는 테스트 스위트 | 가능한 값 |
|---|---|---|---|
assembly-output | 검사할 어셈블리 출력 종류 | assembly | emit-asm, bpf-linker, ptx-linker |
보조 빌드
보조 크레이트 빌드하기를 참조하십시오
| 지시문 | 설명 | 지원되는 테스트 스위트 | 가능한 값 |
|---|---|---|---|
aux-bin | 보조 바이너리를 빌드하며, 빌드된 바이너리는 테스트 디렉터리 기준 auxiliary/bin에서 사용할 수 있습니다 | run-make/run-make-cargo를 제외한 모든 스위트 | 보조 .rs 파일 경로 |
aux-build | 지정한 소스 파일로 별도의 크레이트를 빌드합니다 | run-make/run-make-cargo를 제외한 모든 테스트 스위트 | 보조 .rs 파일 경로 |
aux-crate | aux-build와 같지만 extern prelude로 사용할 수 있게 합니다 | run-make/run-make-cargo를 제외한 모든 테스트 스위트 | [<extern_modifiers>:]<extern_prelude_name>=<path/to/aux/file.rs> |
aux-codegen-backend | aux-build와 비슷하지만, 메인 파일을 빌드할 때 컴파일된 dylib를 -Zcodegen-backend에 전달합니다 | ui-fulldeps | 코드 생성 백엔드 파일 경로 |
proc-macro | aux-build와 비슷하지만, 보조 크레이트를 호스트용으로 강제 빌드하고 -Cprefer-dynamic1을 사용하지 않습니다. | run-make/run-make-cargo를 제외한 모든 테스트 스위트 | 보조 proc-macro .rs 파일 경로 |
build-aux-docs | 보조 크레이트의 문서도 함께 빌드합니다. 이 기능은 aux-crate가 아닌 aux-build에서만 동작한다는 점에 유의하십시오. | run-make/run-make-cargo를 제외한 모든 테스트 스위트 | 해당 없음 |
결과 기대치 제어하기
통과/실패 기대치 제어하기를 참고하십시오.
| 지시문 | 설명 | 지원되는 테스트 스위트 | 가능한 값 |
|---|---|---|---|
check-pass | 빌드(코드 생성 제외)가 통과해야 합니다 | ui | 해당 없음 |
check-fail | 빌드(코드 생성 제외)가 실패해야 합니다 | ui | 해당 없음 |
build-pass | 빌드가 성공해야 합니다 | ui | 해당 없음 |
build-fail | 빌드가 실패해야 합니다 | ui | 해당 없음 |
run-pass | 프로그램이 코드 0으로 종료되어야 합니다 | ui | 해당 없음 |
run-fail | 프로그램이 코드 1..=127로 종료되어야 합니다 | ui | 해당 없음 |
run-crash | 프로그램이 크래시해야 합니다 | ui | 해당 없음 |
run-fail-or-crash | 프로그램이 run-fail 또는 run-crash에 해당해야 합니다 | ui | 해당 없음 |
no-pass-override | --pass 플래그를 무시합니다 | ui | 해당 없음 |
dont-check-failure-status | 정확한 실패 상태(예: 1)를 검사하지 않습니다 | ui, incremental | 해당 없음 |
failure-status | 실패 시 컴파일러는 이 상태 코드로 종료해야 합니다. ICE를 예상하려면 //@ failure-status: 101을 사용하십시오. | ui, incremental | 임의의 u16 |
should-fail | Compiletest 자체 테스트 | 전체 | 해당 없음 |
출력 스냅숏과 정규화 제어
자세한 내용은 정규화, 출력 비교, Rustfix 테스트를 참고하십시오.
| 지시문 | 설명 | 지원되는 테스트 스위트 | 가능한 값 |
|---|---|---|---|
check-run-results | 실행 테스트 바이너리의 run-{pass,fail} 출력 스냅숏을 검사합니다 | ui, crashes, incremental | 해당 없음 |
error-pattern | 출력에 특정 문자열이 포함되어 있는지 검사합니다 | ui, crashes, incremental | 문자열 |
regex-error-pattern | 출력에 정규식 패턴이 포함되어 있는지 검사합니다 | ui, crashes, incremental | 정규식 |
check-stdout | 테스트 바이너리를 실행한 결과의 stdout을 error-pattern들과 대조하여 검사합니다2 | ui, crashes, incremental | 해당 없음 |
normalize-stderr-32bit | 스냅숏과 비교하기 전에 "<raw>" -> "<normalized>" 규칙으로 실제 stderr(32비트 플랫폼용)를 정규화합니다 | ui, incremental | "<RAW>" -> "<NORMALIZED>", <RAW>/<NORMALIZED>는 정규식 캡처 및 치환 구문입니다 |
normalize-stderr-64bit | 스냅숏과 비교하기 전에 "<raw>" -> "<normalized>" 규칙으로 실제 stderr(64비트 플랫폼용)를 정규화합니다 | ui, incremental | "<RAW>" -> "<NORMALIZED>", <RAW>/<NORMALIZED>는 정규식 캡처 및 치환 구문입니다 |
normalize-stderr | 스냅숏과 비교하기 전에 "<raw>" -> "<normalized>" 규칙으로 실제 stderr를 정규화합니다 | ui, incremental | "<RAW>" -> "<NORMALIZED>", <RAW>/<NORMALIZED>는 정규식 캡처 및 치환 구문입니다 |
normalize-stdout | 스냅숏과 비교하기 전에 "<raw>" -> "<normalized>" 규칙으로 실제 stdout을 정규화합니다 | ui, incremental | "<RAW>" -> "<NORMALIZED>", <RAW>/<NORMALIZED>는 정규식 캡처 및 치환 구문입니다 |
dont-check-compiler-stderr | 실제 컴파일러 stderr를 stderr 스냅숏과 비교하지 않습니다 | ui | 해당 없음 |
dont-check-compiler-stdout | 실제 컴파일러 stdout을 stdout 스냅숏과 비교하지 않습니다 | ui | 해당 없음 |
dont-require-annotations | 지정한 진단 종류(//~ KIND)의 줄 주석이 빠짐없이 달려 있을 것을 요구하지 않습니다 | ui, incremental | ERROR, WARN, NOTE, HELP, SUGGESTION |
run-rustfix | rustfix로 모든 제안을 적용하고, 수정된 출력의 스냅숏을 만든 뒤, 수정된 출력이 빌드되는지 확인합니다 | ui | 해당 없음 |
rustfix-only-machine-applicable | run-rustfix와 같지만 기계적으로 적용 가능한 제안만 적용합니다 | ui | 해당 없음 |
exec-env | 테스트를 실행할 때 설정할 환경 변수 | ui, crashes | <KEY>=<VALUE> |
unset-exec-env | 테스트를 실행할 때 해제할 환경 변수 | ui, crashes | 임의의 환경 변수 이름 |
stderr-per-bitwidth | 비트 폭마다 stderr 스냅숏을 생성합니다 | ui | 해당 없음 |
forbid-output | 컴파일/실행 출력에 특정 문자열이 포함되지 않는지 확인합니다 | ui, incremental | 문자열 |
run-flags | 테스트 실행 파일에 전달되는 플래그 | ui | 임의의 플래그 |
known-bug | 알려진 버그이므로 오류 주석이 필요하지 않습니다 | ui, crashes, incremental | 이슈 번호 #123456 |
compare-output-by-lines | 출력을 하나의 문자열이 아니라 줄 단위로 비교합니다 | 전체 | 해당 없음 |
테스트 실행 시점 제어
이 지시문들은 특정 상황에서 테스트를 무시하는 데 사용되며, 이 경우 테스트는 컴파일되거나 실행되지 않습니다.
ignore-X:X는 테스트를 무시할 기준이 되는 타깃 세부 사항이나 기타 조건입니다(아래 참조)only-X는ignore-X와 비슷하지만, 해당 타깃이나 스테이지에서만 테스트를 실행합니다ignore-auxiliary는 하나 이상의 다른 주 테스트 파일에 참여하지만compiletest가 파일 자체를 빌드하려고 해서는 안 되는 파일을 위한 것입니다. 이 보조 파일을 실제로 사용하는 주 테스트로의 역링크를 달아 주십시오.ignore-test는 항상 테스트를 무시합니다. 이는 테스트가 현재 동작하지 않지만 나중에 다시 활성화할 수 있도록 트리 안에 유지하고 싶을 때, 테스트를 일시적으로 비활성화하는 데 사용할 수 있습니다.
ignore-X 또는 only-X에서 X의 몇 가지 예는 다음과 같습니다:
- 전체 타깃 트리플:
aarch64-apple-ios - 아키텍처:
aarch64,arm,csky,mips,mips64,wasm32,x86_64,x86, … - OS:
android,emscripten,freebsd,ios,linux,macos,windows, … - 환경(타깃 트리플의 네 번째 단어):
gnu,msvc,musl - 포인터 너비:
32bit,64bit - 엔디언:
endian-big - 스테이지:
stage1,stage2 - 바이너리 형식:
elf - 채널:
stable,beta - 크로스 컴파일할 때:
cross-compile - 원격 테스트를 사용할 때:
remote - 특정 디버거를 테스트할 때:
cdb,gdb,lldb - 특정 디버거 버전이 일치할 때:
ignore-gdb-version - 병렬 프런트엔드가 활성화된 경우:
ignore-parallel-frontend - 특정 비교 모드:
compare-mode-polonius,compare-mode-next-solver,compare-mode-next-solver-coherence,compare-mode-split-dwarf,compare-mode-split-dwarf-single - 커버리지 테스트에서 사용하는 두 가지 테스트 모드:
ignore-coverage-map,ignore-coverage-run - dist 툴체인을 테스트할 때:
dist- 이 기능은
COMPILETEST_ENABLE_DIST_TESTS=1로 활성화해야 합니다.
- 이 기능은
- 대상의
rustc_abi입니다. 예:rustc_abi-x86_64-sse2
다음 지시문은 rustc 빌드 설정과 대상 설정을 검사합니다.
needs-asm-support— 호스트 아키텍처가asm!을 안정적으로 지원하지 않으면 무시합니다.--target을 통해 명시적인 대상으로 교차 컴파일하는 테스트에서는 적절한 백엔드를 사용할 수 있도록 대신needs-llvm-components를 사용하십시오.needs-asm-ret- 대상의 어셈블리 구문에ret명령어가 없으면 무시합니다. 대부분의 대상 아키텍처에는 이 명령어가 있어 이식 가능한 인라인 어셈블리 테스트에 유용하지만, 일부 아키텍처(예: 32비트 ARM)에는 이 명령어가 없습니다.needs-profiler-runtime— 대상에 대해 프로파일러 런타임이 활성화되지 않았으면(bootstrap.toml의build.profiler = true) 테스트를 무시합니다.needs-sanitizer-support— 대상에 대해 새니타이저 지원이 활성화되지 않았으면(bootstrap.toml의build.sanitizers = true) 무시합니다.needs-sanitizer-{address,hwaddress,leak,memory,thread}— 대상에 대해 해당 새니타이저(각각 AddressSanitizer, 하드웨어 지원 AddressSanitizer, LeakSanitizer, MemorySanitizer, ThreadSanitizer)가 활성화되지 않았으면 무시합니다.needs-run-enabled— 실행되는 테스트인데 실행이 비활성화되어 있으면 무시합니다. 테스트 실행은x test --run=never플래그로 비활성화할 수 있으며, fuchsia에서 실행하는 경우에도 비활성화됩니다.needs-unwind— 대상이 언와인딩을 지원하지 않으면 무시합니다.needs-rust-lld— rust lld 지원이 활성화되지 않았으면(bootstrap.toml의rust.lld = true) 무시합니다.needs-threads— 대상이 스레딩을 지원하지 않으면 무시합니다.needs-subprocess— 대상이 하위 프로세스를 지원하지 않으면 무시합니다.needs-symlink— 대상이 심볼릭 링크를 지원하지 않으면 무시합니다. Windows에서 개발자가 권한이 필요한 심볼릭 링크 권한을 활성화하지 않은 경우가 이에 해당할 수 있습니다.ignore-std-debug-assertions— std가 디버그 단언을 포함하여 빌드되었으면 무시합니다.needs-std-debug-assertions— std가 디버그 단언 없이 빌드되었으면 무시합니다.ignore-std-remap-debuginfo— std가 소스 경로를 재매핑하여 빌드되었으면 무시합니다.needs-std-remap-debugino— std가 소스 경로를 재매핑하지 않고 빌드되었으면 무시합니다.ignore-rustc-debug-assertions— rustc가 디버그 단언을 포함하여 빌드되었으면 무시합니다.needs-rustc-debug-assertions— rustc가 디버그 단언 없이 빌드되었으면 무시합니다.needs-target-has-atomic— 대상이 지정된 모든 원자적 너비를 지원하지 않으면 무시합니다. 예를 들어//@ needs-target-has-atomic: 8, 16, ptr이 지정된 테스트는 대상이 쉼표로 구분된 원자적 너비 목록을 모두 지원하는 경우에만 실행됩니다.needs-dynamic-linking— 대상이 동적 링크를 지원하지 않으면 무시합니다(이는dylib및cdylib크레이트 타입을 생성할 수 없는지 여부와는 별개입니다).needs-crate-type— 대상 플랫폼이 쉼표로 구분된 목록에 지정된 크레이트 타입 중 하나 이상을 지원하지 않으면 무시합니다. 예를 들어//@ needs-crate-type: cdylib, proc-macro를 지정하면wasm32-unknown-unknown대상은proc-macro크레이트 타입을 지원하지 않으므로 해당 대상에서 테스트가 무시됩니다.needs-target-std— 대상 플랫폼이 std를 지원하지 않으면 무시합니다.ignore-backends— 공백 문자로 구분하여 나열한 백엔드를 무시합니다. 이 지시문은--bypass-ignore-backends=[BACKEND]명령줄 플래그로 재정의할 수 있다는 점에 유의하십시오.needs-backends— 현재 코드 생성 백엔드가 목록에 있는 경우에만 테스트를 실행합니다.needs-offload— LLVM 백엔드가 오프로드 지원 없이 빌드된 경우 테스트를 무시합니다.needs-enzyme— Enzyme 서브모듈이 빌드되지 않은 경우 테스트를 무시합니다.
다음 지시문은 LLVM 지원 여부를 확인합니다.
exact-llvm-major-version: 19— LLVM 메이저 버전이 지정한 LLVM 메이저 버전과 일치하지 않으면 테스트를 무시합니다.min-llvm-version: 13.0— LLVM 버전이 주어진 값보다 낮으면 무시됩니다min-system-llvm-version: 12.0— 시스템 LLVM을 사용하고 그 버전이 주어진 값보다 낮으면 무시됩니다max-llvm-major-version: 19— LLVM 메이저 버전이 주어진 메이저 버전보다 높으면 무시됩니다ignore-llvm-version: 9.0— 특정 LLVM 버전을 무시합니다ignore-llvm-version: 7.0 - 9.9.9— 범위 안(양 끝 포함)의 LLVM 버전을 무시합니다needs-llvm-components: powerpc— 특정 LLVM 컴포넌트가 빌드되지 않은 경우 테스트를 무시합니다. 참고: 해당 컴포넌트가 없으면 CI에서(COMPILETEST_REQUIRE_ALL_LLVM_COMPONENTS가 설정된 경우) 테스트가 실패합니다.needs-forced-clang-based-tests— 환경 변수RUSTBUILD_FORCE_CLANG_BASED_TESTS가 설정되지 않으면 테스트가 무시됩니다. 이 환경 변수를 설정하면 LLVM과 함께 clang도 빌드합니다- 이 환경 변수는 두 개의 CI 작업(
test-x86_64-gnu-debug와test-aarch64-gnu-debug)에서만 설정되며, 이 작업들은run-make테스트의 일부만 실행합니다. 이 지시문이 있는 다른 테스트는 전혀 실행되지 않으며, 이는 대개 원하는 동작이 아닙니다.
- 이 환경 변수는 두 개의 CI 작업(
디버거를 무시하는 지시문은 debuginfo 테스트도 참고하십시오.
테스트 빌드 방식에 영향을 주는 지시어
| 지시문 | 설명 | 지원되는 테스트 스위트 | 가능한 값 |
|---|---|---|---|
compile-flags | 테스트 또는 보조(aux) 파일을 빌드할 때 rustc에 전달하는 플래그 | run-make/run-make-cargo를 제외한 전부 | 유효한 모든 rustc 플래그를 사용할 수 있습니다. 예: -Awarnings -Dfoo. -Cincremental이나 --edition은 사용할 수 없습니다 |
edition | 테스트를 빌드할 때 사용하는 에디션 | run-make/run-make-cargo를 제외한 전부 | 유효한 모든 --edition 값 |
rustc-env | rustc를 실행할 때 설정할 환경 변수 | run-make/run-make-cargo를 제외한 모든 테스트 스위트 | <KEY>=<VALUE> |
unset-rustc-env | rustc 실행 시 해제할 환경 변수 | run-make/run-make-cargo를 제외한 모든 테스트 스위트 | 임의의 환경 변수 이름 |
incremental | 증분 테스트 스위트 외부의 테스트에 대한 올바른 증분 지원 | ui, crashes | 해당 없음 |
no-prefer-dynamic | -C prefer-dynamic을 사용하지 않으며, --crate-type=dylib 사전 설정 플래그를 통해 dylib로 빌드하지 않음 | ui, crashes | 해당 없음 |
(run-make/run-make-cargo 외부의) 테스트 중 증분 테스트 스위트에 속하지 않으면서 증분 테스트를 사용하려는 테스트는 compile-flags를 통해 -C incremental을 전달해서는 안 되며, 대신 //@ incremental 지시문을 사용해야 합니다.
대신 해당 테스트를 올바른 증분 테스트로 작성하는 것을 고려하십시오.
에디션 지시문
//@ edition 지시문에는 정확한 에디션 하나, 양쪽이 제한된 에디션 범위, 또는 왼쪽만 제한된 반개구간 에디션 범위를 지정할 수 있습니다. 이 지시어는 ./x test가 테스트를 실행할 때 사용할 에디션에 영향을 줍니다.
예를 들면 다음과 같습니다.
//@ edition: 2018지시문이 있는 테스트는 2018 에디션에서만 실행됩니다.//@ edition: 2015..2021지시문이 있는 테스트는 2015 에디션과 2018 에디션에서 실행될 수 있으므로, Rust와 마찬가지로 상한은 포함되지 않습니다(상한을 포함하는 Rust의..=에 해당하는 표기는 없다는 점에 유의하십시오). 다만 CI는 범위 내에서 가장 낮은 에디션(이 예에서는 2015)으로만 테스트를 실행합니다.//@ edition: 2018..지시문이 있는 테스트는 2018 에디션 이상에서 실행됩니다. 다만 CI는 범위 내에서 가장 낮은 에디션(이 예에서는 2018)으로만 테스트를 실행합니다.
-- --edition= 인자를 전달하여 ./x test가 특정 에디션을 사용하도록 강제할 수도 있습니다. 그러나 //@ edition 지시문이 있는 테스트는 인자로 전달된 값을 지시문이 지정한 범위 안으로 제한합니다. 예를 들어 ./x test -- --edition=2015를 실행하면 다음과 같습니다.
//@ edition: 2018이 있는 테스트는 2018 에디션으로 실행됩니다.//@ edition: 2015..2021이 있는 테스트는 2015 에디션으로 실행됩니다.//@ edition: 2018..이 있는 테스트는 2018 에디션으로 실행됩니다.
Rustdoc
| 지시문 | 설명 | 지원되는 테스트 스위트 | 가능한 값 |
|---|---|---|---|
doc-flags | 테스트 또는 보조 파일을 빌드할 때 rustdoc에 전달되는 플래그 | rustdoc, rustdoc-js, rustdoc-json | 유효한 모든 rustdoc 플래그 |
테스트 스위트별 지시문
테스트 스위트 rustdoc-html, rustdoc-js/rustdoc-js-std, rustdoc-json에는 각각 추가 지시문 집합이 있는 기능이 있으며, 이 지시문들의 기본 구문은 compiletest 지시문과 비슷하지만 최종적으로는 별도의 도구가 읽고 검사합니다. 자세한 내용은 위에 링크된 각 장을 참고하십시오.
예쁜 출력(pretty printing)
Pretty-printer를 참고하십시오.
기타 지시문
no-auto-check-cfg— 자동 check-cfg를 비활성화합니다(--check-cfg테스트에만 해당)revisions— 여러 번 컴파일합니다forbid-output— 출력에 지정한 문자열이 포함되지 않았는지 검사합니다reference— 레퍼런스의 규칙으로 연결되는 주석입니다disable-gdb-pretty-printers— debuginfo 테스트에서 gdb 예쁜 출력기(pretty printer)를 비활성화합니다
도구별 지시문
다음 지시문은 특정 명령줄 도구를 사용하는 테스트 스위트에서 해당 도구를 호출하는 방식에 영향을 줍니다.
skip-filecheck는 원래 LLVM의FileCheck도구를 실행해 출력을 검사하는 테스트에서 이 도구를 실행하지 않게 합니다.- 코드 생성 테스트, 어셈블리 테스트, mir-opt 테스트에서 사용합니다.
filecheck-flags는 LLVM의FileCheck도구를 실행할 때 추가 플래그를 전달합니다.- 코드 생성 테스트, 어셈블리 테스트, MIR-opt 테스트에서 사용합니다.
llvm-cov-flags는 LLVM의llvm-cov도구를 실행할 때 추가 플래그를 전달합니다.coverage-run모드의 커버리지 테스트에서 사용합니다.
Tidy 전용 지시문
다음 지시문은 tidy 스크립트가 테스트를 검증하는 방식을 제어합니다.
ignore-tidy-target-specific-tests는 테스트를 특정 타깃용으로 컴파일할 때(compile-flag지시문의--target플래그로 지정) 적절한 LLVM 컴포넌트를 요구하는지(needs-llvm-components지시문으로 지정) 확인하는 검사를 비활성화합니다.unused-revision-names- 알 수 없는 리비전 이름을 언급할 때 발생하는 tidy 검사를 억제합니다.
치환
지시문 값에서는 몇 가지 변수를 치환할 수 있으며, 이 변수들은 각각 해당하는 값으로 대체됩니다. 예를 들어 특정 파일의 경로가 포함된 컴파일러 플래그를 전달해야 한다면 다음과 같이 작성할 수 있습니다:
//@ compile-flags: --remap-path-prefix={{src-base}}=/the/src
여기서 센티널 {{src-base}}는 아래에 설명된 적절한 경로로 대체됩니다:
{{cwd}}: compiletest가 실행되는 디렉터리입니다. 이 디렉터리는 체크아웃의 루트가 아닐 수 있으므로 가능하면 사용하지 않아야 합니다.- 예시:
/path/to/rust,/path/to/build/root
- 예시:
{{src-base}}: 테스트가 정의된 디렉터리입니다. 이는 출력 정규화의$DIR과 동일합니다.- 예시:
/path/to/rust/tests/ui/error-codes
- 예시:
{{build-base}}: 테스트의 출력이 저장되는 기본 디렉터리입니다. 이는 출력 정규화의$TEST_BUILD_DIR과 동일합니다.- 예시:
/path/to/rust/build/x86_64-unknown-linux-gnu/test/ui
- 예시:
{{rust-src-base}}: libstd/libcore/… 등이 위치한 sysroot 디렉터리입니다.{{sysroot-base}}: 테스트를 빌드하는 데 사용되는 sysroot 디렉터리의 경로입니다.- 주로 API를 통해 컴파일러를 실행하는
ui-fulldeps테스트를 위한 것입니다.
- 주로 API를 통해 컴파일러를 실행하는
{{target-linker}}: 이 테스트에서-Clinker에 전달될 링커이며, 링커 재정의가 활성화되어 있지 않으면 비어 있습니다.- 주로 API를 통해 컴파일러를 실행하는
ui-fulldeps테스트를 위한 것입니다.
- 주로 API를 통해 컴파일러를 실행하는
{{target}}: 테스트가 컴파일하는 대상 타깃입니다.- 예시:
x86_64-unknown-linux-gnu
- 예시:
이 치환을 사용하는 테스트의 예시는 tests/ui/argfile/commandline-argfile.rs를 참고하십시오.
지시문 추가하기
어떤 테스트 속성이나 동작을 개별 테스트 단위로 정의해야 할 때 새 지시문을 추가합니다. 지시문 속성은 런타임에 지시문의 백업 저장소 역할을 합니다(명령의 현재 값을 보관합니다).
새 지시문 속성을 추가하려면 다음과 같이 하십시오:
src/tools/compiletest/src/directives.rs에서pub struct TestProps선언을 찾아 선언의 끝에 새 공개 속성을 추가하십시오.- 구조체 선언 바로 뒤에 있는
impl TestProps구현 블록을 찾아 새 속성을 기본값으로 초기화하십시오.
새 지시문 파서 추가하기
compiletest는 테스트 파일을 만나면, 역시 src/tools/compiletest/src/directives.rs에 있는 Config 구조체의 구현 블록에 정의된 모든 파서를 호출하여 파일을 한 줄씩 파싱합니다(Config 구조체의 선언 블록은 src/tools/compiletest/src/common.rs에 있다는 점에 유의하십시오). TestProps의 load_from() 메서드는 현재 텍스트 줄을 각 파서에 차례로 전달해 보며, 각 파서는 일반적으로 그 줄이 //@ must-compile-successfully나 //@ failure-status와 같은 특정 주석(//@) 지시문으로 시작하는지 확인합니다. 주석 마커 뒤의 공백은 선택 사항입니다.
파서는 지시문에 따라, 테스트 파일에 지시문으로 지정되기만 하거나 테스트 파일에 매개변수 값이 지정되는 것만으로 해당 지시문 속성의 기본값을 재정의합니다.
impl Config에 정의된 파서는 일반적으로 parse_<directive-name>이라는 이름을 가집니다(케밥 케이스 <directive-command>가 스네이크 케이스 <directive_command>로 바뀐다는 점에 유의하십시오). impl Config는 단순한 존재 여부(parse_name_directive()), directive:parameter(s)(parse_name_value_directive()), 특정 cfg 속성이 정의된 경우에만 수행하는 선택적 파싱(has_cfg_prefix()) 등 흔한 패턴을 쉽게 파싱할 수 있게 해 주는 여러 ‘저수준’ 파서도 정의합니다. 저수준 파서는 impl Config 블록의 끝부분 근처에 있습니다. 불필요하게 파싱 코드를 추가로 작성하지 않도록, 저수준 파서와 그 바로 위에 있는 관련 파서들을 살펴보고 어떻게 사용되는지 반드시 확인하십시오.
구체적인 예로, src/tools/compiletest/src/directives.rs에 있는 parse_failure_status() 파서의 구현은 다음과 같습니다:
@@ -232,6 +232,7 @@ pub struct TestProps {
// customized normalization rules
pub normalize_stdout: Vec<(String, String)>,
pub normalize_stderr: Vec<(String, String)>,
+ pub failure_status: i32,
}
impl TestProps {
@@ -260,6 +261,7 @@ impl TestProps {
run_pass: false,
normalize_stdout: vec![],
normalize_stderr: vec![],
+ failure_status: 101,
}
}
@@ -383,6 +385,10 @@ impl TestProps {
if let Some(rule) = config.parse_custom_normalization(ln, "normalize-stderr") {
self.normalize_stderr.push(rule);
}
+
+ if let Some(code) = config.parse_failure_status(ln) {
+ self.failure_status = code;
+ }
});
for key in &["RUST_TEST_NOCAPTURE", "RUST_TEST_THREADS"] {
@@ -488,6 +494,13 @@ impl Config {
self.parse_name_directive(line, "pretty-compare-only")
}
+ fn parse_failure_status(&self, line: &str) -> Option<i32> {
+ match self.parse_name_value_directive(line, "failure-status") {
+ Some(code) => code.trim().parse::<i32>().ok(),
+ _ => None,
+ }
+ }
동작 변경 구현하기
테스트가 특정 지시문을 호출하면 그 결과로 어떤 동작이 바뀔 것으로 예상됩니다. 물론 어떤 동작이 바뀌는지는 지시문의 목적에 따라 다릅니다. failure-status의 경우, 바뀌는 동작은 compiletest가 기본값 대신 테스트에서 호출한 지시문이 정의한 실패 코드를 기대하게 된다는 것입니다.
이 내용은 failure-status에만 해당하지만(지시문마다 동작 변경을 일으키기 위한 구현이 다르기 때문입니다), 한 가지 사례의 동작 변경 구현을 단순히 예시로 살펴보는 것이 도움이 될 수 있습니다. failure-status를 구현하기 위해 src/tools/compiletest/src/runtest.rs에 있는 TestCx 구현 블록의 check_correct_failure_status() 함수를 다음과 같이 수정했습니다:
@@ -295,11 +295,14 @@ impl<'test> TestCx<'test> {
}
fn check_correct_failure_status(&self, proc_res: &ProcRes) {
- // The value the Rust runtime returns on failure
- const RUST_ERR: i32 = 101;
- if proc_res.status.code() != Some(RUST_ERR) {
+ let expected_status = Some(self.props.failure_status);
+ let received_status = proc_res.status.code();
+
+ if expected_status != received_status {
self.fatal_proc_rec(
- &format!("failure produced the wrong error: {}", proc_res.status),
+ &format!("Error: expected failure status ({:?}) but received status {:?}.",
+ expected_status,
+ received_status),
proc_res,
);
}
@@ -320,7 +323,6 @@ impl<'test> TestCx<'test> {
);
let proc_res = self.exec_compiled_test();
-
if !proc_res.status.success() {
self.fatal_proc_rec("test run failed!", &proc_res);
}
@@ -499,7 +501,6 @@ impl<'test> TestCx<'test> {
expected,
actual
);
- panic!();
}
}
지시문 속성에 접근하기 위해 self.props.failure_status를 사용한다는 점에 주목하십시오. 실패 상태 지시문을 지정하지 않은 테스트에서 self.props.failure_status는 이 글을 쓰는 시점의 기본값인 101로 평가됩니다. 그러나 예를 들어 //@ failure-status: 1과 같은 지시문을 지정한 테스트에서는 parse_failure_status()가 해당 테스트에 한해 TestProps 기본값을 재정의하므로 self.props.failure_status는 1로 평가됩니다.
-
자세한 내용은 compiletest 장의 보조 proc-macro 절을 참고하십시오. ↩
-
현재 이 방식에는 이상한 특이점이 있는데, 테스트 바이너리의 stdout과 stderr가 하나로 이어 붙은 다음 이 결합된 출력에 대해
error-pattern들이 매칭된다는 점으로, 이는 ??? 좋게 말해도 다소 의문스럽습니다. ↩
minicore 테스트 보조 파일: core 스텁 사용하기
tests/auxiliary/minicore.rs는 ui/코드 생성/assembly/mir-opt 테스트 스위트를 위한 테스트 보조 파일입니다. 이 파일은 크로스 컴파일 대상용으로 빌드해야 하지만 실행할 필요가 없거나 실행하지 않으려는 테스트를 위해 core 스텁을 제공합니다.
minicore는 core 항목만을 위한 것이며, std나 alloc 항목은 명시적으로 대상이 아니라는 점에 유의하십시오. core 항목이 더 넓은 범위의 테스트에 적용될 수 있기 때문입니다.
테스트에서 //@ add-minicore 지시문을 지정하면 minicore를 사용할 수 있습니다. 그런 다음 테스트에 #![feature(no_core)] + #![no_std] + #![no_core]를 표시하고, extern crate minicore(2015 에디션) 또는 use minicore(2018+ 에디션)로 크레이트를 테스트에 가져오십시오.
암시되는 컴파일러 플래그
이 테스트들은 no_std + no_core 특성을 가지므로, //@ add-minicore는 테스트가 -C panic=abort로 빌드된다는 것을 암시하며 또한 그렇게 빌드되어야 함을 요구합니다. 언와인딩 패닉은 지원되지 않습니다.
또한 어셈블리 테스트에서 CFI 지시문을 보존하기 위해 테스트는 -C force-unwind-tables=yes로도 빌드됩니다.
요약: //@ add-minicore는 다음 두 가지 컴파일러 플래그를 암시합니다.
-C panic=abort-C force-unwind-tables=yes
core 스텁 더 추가하기
minicore 스텁에 빠진 core 항목을 발견했다면, 그 항목이 사용될 가능성이 높거나 이미 둘 이상의 테스트에서 필요한 경우 테스트 보조 파일에 추가하는 것을 고려하십시오.
core와 동기화 유지하기
minicore 항목은 core에 맞추어 최신 상태로 유지해야 합니다. core를 사용할 때와 minicore를 사용할 때 진단 출력이 일관되도록, 모든 diagnostic 속성(예: on_unimplemented)을 minicore에 그대로 복제해야 합니다.
minicore를 사용하는 코드 생성 테스트 예시
#![allow(unused)]
fn main() {
//@ add-minicore
//@ revisions: meow bark
//@[meow] compile-flags: --target=x86_64-unknown-linux-gnu
//@[meow] needs-llvm-components: x86
//@[bark] compile-flags: --target=wasm32-unknown-unknown
//@[bark] needs-llvm-components: webassembly
#![crate_type = "lib"]
#![feature(no_core)]
#![no_std]
#![no_core]
extern crate minicore;
use minicore::*;
struct Meow;
impl Copy for Meow {} // `Copy` here is provided by `minicore`
// CHECK-LABEL: meow
#[unsafe(no_mangle)]
fn meow() {}
}
생태계 테스트
Rust는 회귀를 찾아내고 언어의 발전 방향을 충분한 근거에 따라 결정하기 위해 생태계의 실제 코드와의 통합을 테스트합니다.
테스트 방법
Crater
Crater는 수천 개의 공개 프로젝트에서 테스트를 실행하는 도구입니다. 이 도구는 실행을 위한 별도의 자체 인프라를 갖추고 있으며, CI의 일부로 실행되지 않습니다. 자세한 내용은 Crater 장을 참조하십시오.
cargotest
cargotest는 몇 가지 예제 프로젝트(stylo, ripgrep, tokei 등)에서 cargo test를 실행하는 작은 도구입니다. 이 도구는 CI의 일부로 실행되며, 심각한 회귀가 없는지 확인합니다.
./x test src/tools/cargotest
대규모 OSS 프로젝트 빌더
CI에는 대규모 오픈 소스 Rust 프로젝트를 빌드하는 CI 작업이 있으며, 이 프로젝트들은 CI에서 회귀 테스트로 사용됩니다. 통합 작업에서는 다음 프로젝트를 빌드합니다.
Crater
Crater는 crates.io에 있는 모든 크레이트(와 GitHub에 있는 일부 크레이트)를 컴파일하고 테스트를 실행하는 도구입니다. 주로 호환성을 깨는 변경이 될 수 있는 사항을 구현할 때 문제가 생기는 범위를 확인하고, 베타와 안정 컴파일러 버전을 비교 실행하여 문제가 없음을 보장하는 데 사용됩니다.
Crater를 실행해야 하는 경우
PR이 컴파일러를 크게 변경하거나 호환성을 깨뜨릴 수 있다면 Crater 실행을 요청해야 합니다. 확실하지 않다면 PR 리뷰어에게 편하게 문의하십시오.
Crater 실행 요청하기
Rust 팀은 PR이 도입한 변경 사항에 대해 crater 실행에 사용할 수 있는 몇 대의 머신을 유지 관리합니다. PR에 Crater 실행이 필요하다면 PR 스레드에 트리아지 팀 앞으로 댓글을 남기십시오. 필요한 Crater 실행이 “확인 전용(check-only)” 실행인지, “빌드 전용(build only)” 실행인지, “빌드 및 테스트(build-and-test)” 실행인지 팀에 알려 주십시오. 주된 차이는 소요 시간입니다. 확실하지 않다면 빌드 및 테스트 실행을 선택하십시오. 컴파일 시점에만 영향을 주는 변경(예: 새 트레이트 구현)이라면 확인 실행만으로 충분합니다.
트리아지 팀이 PR을 대기열에 넣으며, 결과가 준비되면 게시됩니다. 확인 실행은 약 3일에서 4일이 걸리며, 나머지 두 가지는 평균 5일에서 6일이 걸립니다.
Crater는 매우 유용하지만, 몇 가지 주의 사항도 알아 두는 것이 중요합니다.
-
모든 코드가 crates.io에 있는 것은 아닙니다! GitHub을 비롯한 여러 곳의 저장소에도 많은 코드가 있습니다. 또한 기업은 자사 코드를 공개하고 싶어 하지 않을 수 있습니다. 따라서 Crater 실행이 성공했다고 해서 호환성이 깨지지 않는다는 뜻은 아니므로, 여전히 주의해야 합니다.
-
Crater는 x86_64에서 Linux 빌드만 실행합니다. 따라서 다른 아키텍처와 플랫폼은 테스트되지 않습니다. 특히 중요한 점은 여기에 Windows도 포함된다는 것입니다.
-
많은 크레이트가 테스트되지 않습니다. 이유는 여러 가지일 수 있습니다. 크레이트가 더 이상 컴파일되지 않거나(예: 오래된 nightly 기능 사용), 테스트가 깨졌거나 불안정하거나, 네트워크 접근이 필요하거나, 그 밖의 이유가 있을 수 있습니다.
-
Crater를 실행하려면 먼저
@bors try가 산출물 빌드에 성공해야 합니다. 즉, 코드가 컴파일되지 않으면 Crater를 실행할 수 없습니다.
Fuchsia 통합 테스트
Fuchsia는 약 200만 줄의 Rust 코드로 이루어진 오픈 소스 운영 체제입니다.1 Fuchsia는 과거에 많은 회귀를 잡아냈으며, 이후 CI에 포함되었습니다.
Fuchsia 작업이 실패하면 어떻게 해야 합니까?
fuchsia 핑 그룹에 연락하여 도움을 요청하십시오.
@rustbot ping fuchsia
CI에서 Fuchsia 빌드하기
Fuchsia는 풀 리퀘스트가 병합되기 전에 실행되는 bors 테스트 모음의 일부로 빌드됩니다.
풀 리퀘스트가 Fuchsia 빌더를 망가뜨릴까 걱정되어 bors 큐에 제출하기 전에 테스트해 보고 싶다면, Fuchsia 통합을 빌드하는 try 작업을 실행하도록 bors에 요청하기만 하면 됩니다: @bors try jobs=x86_64-fuchsia.
로컬에서 Fuchsia 빌드하기
Fuchsia는 Rust 이외의 언어도 사용하기 때문에 빌드 시스템으로 Cargo를 사용하지 않습니다. 또한 툴체인 빌드가 특정한 방식으로 구성되어 있어야 합니다.
Fuchsia를 빌드하는 권장 방법은 Fuchsia를 체크아웃하고 빌드를 실행해 주는 Docker 스크립트를 사용하는 것입니다. 이전에 Docker 테스트를 실행해 본 적이 있다면, Rust 체크아웃에서 이 명령을 실행하기만 하면 로컬 Rust 툴체인을 사용하여 Fuchsia를 다운로드하고 빌드할 수 있습니다.
src/ci/docker/run.sh x86_64-fuchsia
Docker로 작업을 실행하고 디버깅하는 방법에 대한 자세한 내용은 Docker로 테스트하기 장을 참고하십시오.
Fuchsia 체크아웃은 용량이 크다는 점에 유의하십시오. 이 글을 쓰는 시점 기준으로 체크아웃과 빌드에 46G의 공간이 필요하며, 짐작하시겠지만 완료하는 데 시간이 꽤 걸립니다.
Fuchsia 체크아웃 수정하기
로컬에서 Fuchsia를 빌드하려는 주된 이유는 회귀를 조사해야 하기 때문입니다. Docker 빌드를 실행하고 나면 Rust 체크아웃의 obj/fuchsia 디렉터리 안에서 Fuchsia 체크아웃을 찾을 수 있습니다. build-fuchsia.sh 스크립트의 KEEP_CHECKOUT 줄을 KEEP_CHECKOUT=1로 수정하면, 필요에 따라 체크아웃을 변경하고 위의 빌드 명령을 다시 실행할 수 있습니다. 이렇게 하면 이전의 빌드 결과를 모두 재사용합니다.
Fuchsia 체크아웃을 사용자 지정하는 더 많은 옵션은 build-fuchsia.sh 스크립트에서 찾을 수 있습니다.
Fuchsia 빌드 사용자 지정하기
Rust CI에서 Fuchsia를 빌드하는 데 사용되는 옵션에 대한 자세한 정보는 build-fuchsia.sh가 호출하는 build_fuchsia_from_rust_ci.sh 스크립트에서 찾을 수 있습니다.
Fuchsia 빌드 시스템은 Ninja 파일을 생성한 다음 빌드 실행 작업을 Ninja에 넘기는 메타빌드 시스템인 GN을 사용합니다.
Fuchsia 개발자는 빌드를 실행하고 기타 개발 작업을 수행하는 데 fx를 사용합니다. 이 도구는 Fuchsia 체크아웃의 .jiri_root/bin에 있습니다. 일부 워크플로에서는 이 경로를 $PATH에 추가해야 할 수도 있습니다.
관련된 fx 하위 명령이 몇 가지 있으며, 다음과 같습니다:
fx set은 빌드 인자를 받아out/default/args.gn에 기록한 뒤 GN을 실행합니다.fx build는 Ninja를 사용하여 Fuchsia 프로젝트를 빌드합니다. 빌드 인자가 변경되면 이를 자동으로 반영하여 GN을 다시 실행합니다. 기본적으로 모든 것을 빌드하지만, 특정 타깃을 빌드하기 위해 타깃 경로를 받을 수도 있습니다(아래 참고).fx clippy는 특정 Rust 타깃(또는 모든 타깃)에 대해 Clippy를 실행합니다. Rust CI 빌드에서는 대부분의 Rust 타깃에 대한 코드 생성을 피하기 위해 이 명령을 사용합니다. 내부적으로는fx build와 마찬가지로 Ninja를 호출합니다. clippy 결과는 출력되기 전에 빌드 출력 디렉터리 안의 json 파일에 저장됩니다.
타깃 경로
GN은 빌드 타깃을 식별하기 위해 다음과 같은 경로를 사용합니다.
//src/starnix/kernel:starnix_core
맨 앞의 //는 체크아웃의 루트를 의미하며, 나머지 슬래시로 구분된 부분은 디렉터리 이름입니다. : 뒤의 문자열은 해당 디렉터리의 BUILD.gn 파일에 정의된 타깃의 타깃 이름입니다.
타깃 이름이 디렉터리 이름과 같으면 생략할 수 있습니다. 즉, //src/starnix/kernel은 //src/starnix/kernel:kernel과 같습니다.
이러한 타깃 경로는 BUILD.gn 파일 안에서 의존성을 참조하는 데 사용되며, fx build에서도 사용할 수 있습니다.
컴파일러 플래그 수정하기
타깃에 추가되는 GN config 안에 사용자 지정 컴파일러 플래그를 넣을 수 있습니다. 간단한 예시는 다음과 같습니다.
config("everybody_loops") {
rustflags = [ "-Zeverybody-loops" ]
}
rustc_binary("example") {
crate_root = "src/bin.rs"
# ...existing keys here...
configs += [ ":everybody_loops" ]
}
이렇게 하면 example 타깃을 빌드할 때 rustc에 -Zeverybody-loops 플래그가 추가됩니다. 해당 타깃에 의존하는 모든 타깃에 config를 추가하려면 public_configs를 사용할 수도 있다는 점에 유의하십시오.
빌드 내 모든 Rust 타깃에 플래그를 추가하려면 //build/config:compiler config나 그 파일에서 참조하는 OS별 config에 rustflags를 추가하면 됩니다. Rust 타깃에서는 cflags와 ldflags가 무시된다는 점에 유의하십시오.
ninja 및 rustc 명령 직접 실행하기
한 단계 더 내려가 보면, fx build는 ninja를 호출하고, ninja는 결국 rustc를 호출합니다. 모든 빌드 작업은 out 디렉터리 안에서 실행되며, 이 디렉터리는 보통 Fuchsia 체크아웃 안의 out/default입니다.
타깃의 소스 파일 중 하나에 구문 오류를 추가하는 등의 방법으로 명령이 실패하도록 만들면, ninja가 실제로 호출하는 명령을 출력하게 할 수 있습니다. 명령을 얻은 뒤에는 출력 디렉터리 안에서 해당 명령을 실행할 수 있습니다.
툴체인 자체를 변경한 후에는 fx build나 ninja가 모든 Rust 타깃을 다시 빌드하도록 out/default/args.gn의 빌드 설정 rustc_version_string을 변경해야 합니다. 이 작업은 텍스트 편집기로 할 수 있으며, 빌드할 때마다 값이 바뀌기만 하면 문자열의 내용은 중요하지 않습니다. build_fuchsia_from_rust_ci.sh는 툴체인 디렉터리를 해싱하여 이 작업을 대신 수행합니다.
Fuchsia 웹사이트에 빌드 시스템에 대한 더 자세한 문서가 있습니다.
기타 팁과 요령
build_fuchsia_from_rust_ci.sh를 사용할 때는 첫 실행 후 fx set 명령을 주석 처리하여 매번 GN이 다시 실행되지 않도록 할 수 있습니다. 이렇게 하는 경우 version_string 줄도 주석 처리하여 몇 초를 절약할 수 있습니다.
첫 빌드 이후 ninja 시작 시간을 단축하려면 export NINJA_PERSISTENT_MODE=1을 실행하십시오.
Fuchsia 타깃 지원
Fuchsia 타깃 지원에 대해 자세히 알아보려면 rustc 책의 Fuchsia 장을 참고하십시오.
-
2024년 6월 기준으로 Fuchsia에는 tokei로 집계한 결과(주석과 빈 줄 제외) 약 200만 줄의 자체 Rust 코드와 거의 같은 양의 서드파티 코드가 있었습니다. ↩
Rust for Linux 통합 테스트
Rust for Linux(RfL)는 Linux 커널에 Rust 프로그래밍 언어 지원을 추가하기 위한 프로젝트입니다.
Rust for Linux 작업이 깨지면 어떻게 해야 합니까?
PR이 Rust for Linux CI 작업을 깨뜨리면 다음과 같이 하십시오:
- 의도하지 않은 깨짐이고 일시적인 오류로 보인다면 RfL에 알리고 다시 시도하십시오.
- PR이 긴급하고 재시도로도 해결되지 않는다면 CI 작업을 일시적으로 비활성화하십시오(
src/ci/github-actions/jobs.yml에서image: x86_64-rust-for-linux작업을 주석 처리하십시오).
- PR이 긴급하고 재시도로도 해결되지 않는다면 CI 작업을 일시적으로 비활성화하십시오(
- 의도하지 않은 깨짐이라면 PR을 수정하여 깨짐을 해결하십시오.
- 의도한 깨짐이라면 RfL에 알리고 커널에서 무엇을 변경해야 하는지 논의하십시오.
- PR이 긴급하다면 CI 작업을 일시적으로 비활성화하십시오(
src/ci/github-actions/jobs.yml에서image: x86_64-rust-for-linux작업을 주석 처리하십시오). - PR이 며칠 기다릴 수 있다면 RfL 메인테이너가 필요한 변경 사항을 반영한 새 Linux 커널 커밋 해시를 제공할 때까지 기다린 다음, 이를 PR에 적용하여 변경 사항이 동작하는지 확인하십시오(
src/ci/docker/scripts/rfl-build.sh의LINUX_VERSION환경 변수를 업데이트하십시오).
- PR이 긴급하다면 CI 작업을 일시적으로 비활성화하십시오(
RfL 개발자에게 연락해야 한다면 Rust for Linux 핑 그룹을 핑하여 도움을 요청할 수 있습니다:
@rustbot ping rfl
CI에서 Rust for Linux 빌드하기
Rust for Linux는 풀 리퀘스트가 병합되기 전에 실행되는 bors 테스트 모음의 일부로 빌드됩니다.
이 워크플로는 Rust 컴파일러의 stage1 sysroot를 빌드하고, Linux 커널을 내려받은 다음, 이 sysroot를 사용하여 여러 Rust for Linux 드라이버와 예제를 컴파일해 봅니다. RfL은 여러 불안정한 컴파일러/언어 기능을 사용하므로, 이 워크플로는 특정 컴파일러 변경이 RfL을 깨뜨리는 경우 이를 알려 줍니다.
풀 리퀘스트가 Rust for Linux 빌더를 깨뜨릴까 걱정되어 bors 대기열에 제출하기 전에 테스트해 보고 싶다면, Rust for Linux 통합을 빌드하는 try 작업을 실행하도록 bors에 요청하기만 하면 됩니다: @bors try jobs=x86_64-rust-for-linux.
코드 생성 백엔드 테스트
코드 생성 장도 참고하십시오.
rust-lang/rust CI는 주 LLVM 코드 생성 백엔드 외에도 일부 테스트 작업에서 cranelift 및 GCC 코드 생성 백엔드의 테스트도 실행합니다.
관련 테스트에 대한 자세한 내용은 다음을 참고하십시오.
Cranelift 코드 생성 백엔드 테스트
TODO: 이 페이지에 정보를 더 추가해 주십시오.
GCC 코드 생성 백엔드
이 백엔드와 컴파일러의 통합을 깨뜨릴 수 있는 변경 사항을 찾아내기 위해, CI에서 GCC 코드 생성 백엔드로 컴파일러 테스트 스위트의 일부를 실행합니다.
GCC 코드 생성 백엔드 전반에 관한 버그나 문제를 발견하면 주저하지 말고 rustc_codegen_gcc 저장소에 이슈를 등록하십시오.
현재 이 백엔드는 x86_64-unknown-linux-gnu 타깃만 지원한다는 점에 유의하십시오.
GCC 백엔드 CI 오류가 발생한 경우
CI의 test-x86_64-gnu-gcc 작업에서 GCC 코드 생성 백엔드로 실행된 테스트와 관련된 오류가 발생했다면, 다음 명령으로 GCC 백엔드를 사용해 로컬에서 UI 테스트를 실행하여 CI에서 일어나는 일을 재현할 수 있습니다:
./x test tests/ui \
--set 'rust.codegen-backends = ["llvm", "gcc"]' \
--set 'rust.debug-assertions = false' \
--test-codegen-backend gcc
CI에서 다른 테스트 스위트가 실패했다면 tests/ui 부분을 수정해야 합니다.
CI 작업 전체를 로컬에서 재현하려면 cargo run --manifest-path src/ci/citool/Cargo.toml run-local test-x86_64-gnu-gcc를 실행하면 됩니다. 자세한 내용은 Docker로 테스트하기를 참고하십시오.
GCC 작업이 실패하면 어떻게 해야 합니까?
GCC 작업의 테스트가 실패했고 그 실패가 GCC 백엔드 때문일 수 있다고 판단되면, @rust-lang/wg-gcc-backend를 사용해 cg-gcc 워킹 그룹을 호출할 수 있습니다
GCC 백엔드에서 실패하는 컴파일러 테스트를 고치는 것이 간단하지 않다면, //@ ignore-backends: gcc compiletest 지시문을 사용하여 cg_gcc로 실행될 때 해당 테스트를 무시하도록 할 수 있습니다.
빌드할 코드 생성 백엔드 선택하기
rust.codegen-backends = [...] 부트스트랩 옵션은 어떤 코드 생성 백엔드를 빌드하여 생성된 rustc의 sysroot에 포함할지를 결정합니다. GCC 코드 생성 백엔드를 사용하려면 bootstrap.toml의 이 배열에 "gcc"가 포함되어야 합니다:
rust.codegen-backends = ["llvm", "gcc"]
bootstrap.toml 파일을 변경하고 싶지 않다면, 대신 --set 'rust.codegen-backends=["llvm", "gcc"]'와 함께 x 명령을 실행할 수도 있습니다. 예를 들면 다음과 같습니다.
./x build --set 'rust.codegen-backends=["llvm", "gcc"]'
codegen-backends 배열의 첫 번째 백엔드가 빌드된 rustc의 기본 백엔드로 사용될 백엔드를 결정합니다. 또한 이것은 stage 1 표준 라이브러리(또는 stage 2 이상에서 빌드되는 모든 것)를 컴파일하는 데 어떤 백엔드를 사용할지도 결정합니다. 따라서 기본적으로 GCC 백엔드를 사용하는 rustc를 만들려면 이 배열의 첫 번째 요소로 "gcc"를 두면 됩니다:
./x build --set 'rust.codegen-backends=["gcc"]' library
테스트에 사용할 코드 생성 백엔드 선택하기
테스트용 Rust 프로그램을 빌드하는 데 GCC 코드 생성 백엔드를 사용하여 컴파일러 테스트를 실행하려면 --test-codegen-backend 플래그를 사용할 수 있습니다:
./x test tests/ui --test-codegen-backend gcc
이 방법이 동작하려면 테스트 대상 컴파일러의 sysroot 디렉터리에서 GCC 코드 생성 백엔드를 사용할 수 있어야 한다는 점에 유의하십시오.
CI에서 GCC 내려받기
gcc.download-ci-gcc 부트스트랩 옵션은 GCC(GCC 코드 생성 백엔드의 의존성)를 CI에서 내려받을지 로컬에서 빌드할지를 제어합니다. 기본값은 true이며, 이 경우 GCC 소스에 로컬 변경 사항이 없고 해당 호스트 타깃을 CI에서 사용할 수 있으면 CI에서 GCC를 내려받습니다.
직접 GCC 제공하기
직접 준비한 libgccjit.so 파일을 제공하고 싶은 경우가 있습니다. 그러한 경우 중 하나는 GCC가 다중 타깃 컴파일러가 아니기 때문에 rustc를 다른 타깃으로 교차 컴파일하려는 경우입니다. 이러한 사용 사례를 지원하기 위해 gcc.libgccjit-libs-dir 부트스트랩 옵션이 있습니다. 이 옵션은 gcc.download-ci-gcc보다 우선하며, 이는 부트스트랩이 libgccjit.so를 내려받거나 로컬에서 빌드하지 않는다는 뜻입니다. 이 디렉터리의 구조는 <host>/<target>/libgccjit.so이며, 예를 들면 다음과 같습니다:
.
├── m68k-unknown-linux-gnu
│ └── m68k-unknown-linux-gnu
│ └── libgccjit.so
└── x86_64-unknown-linux-gnu
├── m68k-unknown-linux-gnu
│ └── libgccjit.so
└── x86_64-unknown-linux-gnu
└── libgccjit.so
백엔드 자체의 테스트 실행하기
GCC 코드 생성 백엔드를 사용하여 컴파일러의 테스트 스위트를 실행하는 것 외에도, 백엔드 자체의 테스트 스위트도 실행할 수 있습니다.
다음 명령을 사용하여 실행합니다.
./x test rustc_codegen_gcc
이 작업을 수행하려면 백엔드가 활성화되어 있어야 합니다.
성능 테스트
성능 고려 사항
컴파일러 성능은 중요합니다. 저희는 지난 몇 년간 컴파일러 성능을 점진적으로 개선하는 데 많은 노력을 기울여 왔습니다.
변경 사항이 성능 회귀(또는 개선)를 일으킬 수 있다고 의심된다면 “perf 실행“을 요청할 수 있습니다(리뷰어도 승인하기 전에 perf 실행을 요청할 수 있습니다). 이것은 변경 사항이 적용된 컴파일러로 벤치마크 모음을 컴파일하는 또 하나의 봇입니다. 수치는 여기에 보고되며, 변경 사항을 최신 main과 비교한 결과를 볼 수 있습니다.
rustc 개발에도 유용한 Rust 코드 성능 전반에 대한 소개는 The Rust Performance Book을 참조하십시오.
rustc-perf
컴파일러의 성능을 개선하고 성능 회귀를 방지하는 데 많은 노력이 들어갑니다.
rustc-perf 프로젝트는 성능을 테스트하고 추적하기 위한 여러 서비스를 제공합니다. 이 프로젝트는 벤치마크를 서비스 형태로 실행하기 위한 호스팅 인프라를 제공합니다. 현재는 x86_64-unknown-linux-gnu 빌드만 추적합니다.
“perf 실행“은 널리 쓰이는 대규모 크레이트 모음에 대해 다양한 구성에서 컴파일러의 성능을 비교하는 데 사용됩니다. 다양한 구성에는 “새 빌드”, 증분 컴파일을 사용한 빌드 등이 있습니다.
perf 실행의 결과는 (커밋 해시로 식별되는) 두 버전의 컴파일러 간 비교입니다.
rustc-perf를 사용하여 로컬에서 컴파일러를 직접 벤치마크하고 프로파일링할 수도 있습니다.
자동 perf 실행
모든 PR이 병합된 후에는 컴파일러를 대상으로 벤치마크 모음이 실행됩니다. 결과는 https://perf.rust-lang.org/ 웹사이트에서 시간에 따라 추적됩니다. 변경 사항이 있으면 PR에 댓글로 기록됩니다.
수동 perf 실행
또한 필요에 따라 PR이 병합되기 전에 성능 테스트를 실행할 수도 있습니다. PR이 성능에 영향을 줄 수 있다면, 특히 성능에 악영향을 줄 수 있다면 perf 실행을 요청해야 합니다.
PR의 성능 영향을 평가하려면 PR에 다음 댓글을 작성하십시오.
@bors try @rust-timer queue
참고: perf 실행 권한이 있는 사용자만 이 댓글을 게시할 수 있습니다. 이를 사용할 수 있는 팀은 Teams 저장소에서
[permissions]섹션의perf = true값으로 관리됩니다(bors 권한도 필요합니다). 해당 팀에 속해 있지 않다면 다른 사람에게 대신 게시해 달라고 부담 없이 요청하십시오(Zulip에서 요청하거나 배정된 리뷰어에게 요청하십시오).
이 댓글은 먼저 bors에게 x86_64-unknown-linux-gnu용 전체 릴리스 빌드를 수행하는 “try” 빌드를 하도록 지시합니다. 빌드가 끝나면 해당 빌드를 대상으로 성능 테스트 모음을 실행하도록 대기열에 넣습니다. 성능 테스트가 끝나면 봇이 요약과 전체 보고서 링크가 담긴 댓글을 PR에 게시합니다.
이미 빌드된 산출물(예: 아직 벤치마크되지 않은 이전 try 빌드)에 대해 perf 실행을 하려면 대신 다음을 실행할 수 있습니다:
@rust-timer build <commit-sha>
다만 같은 산출물을 두 번 벤치마크할 수는 없습니다.
사용할 수 있는 perf 봇 명령에 대한 자세한 정보는 여기에서 확인할 수 있습니다.
벤치마킹 과정 자체에 대한 더 자세한 내용은 perf collector 문서에서 확인할 수 있습니다.
Autodiff CI 작업
optional-test-x86_64-gnu-autodiff 작업은 실험적 기능인 autodiff와 이 기능의 LLVM Enzyme 통합을 지속적으로 테스트합니다. 이 작업은 선택적인 auto 작업이므로 실패하더라도 풀 리퀘스트 병합이 막히지 않습니다.
이 기능에 대한 자세한 배경은 autodiff 추적 이슈와 autodiff 내부 구조 장을 참고하십시오.
테스트 대상
이 작업은 다음을 검사합니다.
- 순방향 및 역방향 모드의 매크로 확장과 진단
- autodiff를 위한 LLVM IR 생성
autodiff기능 게이트 적용
작업 실행하기
try 빌드에서 이 작업을 실행하려면 풀 리퀘스트에 다음과 같이 댓글을 다십시오.
@bors try jobs=optional-x86_64-gnu-autodiff
이 작업을 로컬에서 실행하려면 Rust 체크아웃 디렉터리에서 다음 명령을 실행하십시오.
$ cargo run --manifest-path src/ci/citool/Cargo.toml run-local optional-x86_64-gnu-autodiff
CI 작업을 로컬에서 실행하는 방법에 대한 자세한 내용은 Docker로 테스트하기를 참고하십시오.
연락처
이 작업에 대해 질문이 있거나 작업 실패와 관련해 도움이 필요하면 autodiff Zulip 채널에 새 토픽을 여십시오. Enzyme 백엔드 문제로 실패한 것으로 의심되면 autodiff 디버깅 가이드를 참고하십시오.
CI에서 차세대 솔버와 polonius 알파의 안정화 전 테스트
2026년에 차세대 솔버와 polonius 알파를 안정화하는 것을 목표로, 두 기능의 지속적인 테스트 커버리지를 개선하고자 합니다. 이를 위해 이 불안정 기능들이 회귀하지 않도록 충분히 테스트되고, 앞으로도 계속 충분히 테스트되도록 보장해야 합니다.
따라서 회귀를 잡아내기 위한 전용 CI 작업 test-x86_64-gnu-next-trait-solver-polonius이 필요하며, 이 작업은 PR CI에서 차단 작업이므로 Merge CI에서도 차단 작업입니다.
자세한 배경은 다음을 참고하십시오.
테스트 대상
- 차세대 솔버의 경우:
- 차세대 솔버로 표준 라이브러리를 빌드할 수 있는지 확인하고자 합니다.
- 차세대 솔버로 전체 툴체인을 부트스트랩할 수 있는지 확인하고자 합니다.
- stage 1에서 차세대 솔버를 활성화한 상태로 stage 2 라이브러리를 빌드하여 두 가지를 모두 확인합니다.
- polonius 알파의 경우:
- 차세대 솔버를 활성화하여 빌드한 stage 1 컴파일러/std를 대상으로, polonius 비교 모드에서 UI 테스트 스위트를 실행합니다.
참고
실패를 로컬에서 재현하려면 다음과 같이 하십시오.
$ RUSTFLAGS_NOT_BOOTSTRAP="-Znext-solver=globally" ./x build library --stage 2$ RUSTFLAGS_NOT_BOOTSTRAP="-Znext-solver=globally" ./x test tests/ui --compare-mode polonius --stage 1
이 조합에만 해당할 수 있는 실패와 해결 방법에는 어떤 것이 있습니까?
- 표준 라이브러리나 컴파일러에서 불안정 기능을 사용할 때 새로운 오류가 발생할 수 있습니다.
- 때로는 새 테스트를 bless해야 할 수 있으며, 드물게는 테스트 어노테이션이나 리비전을 추가해야 할 수도 있습니다.
문의처
이 작업의 테스트 실패와 관련하여 질문이 있거나 조언이 필요하시면 t-compiler(컴파일러) zulip 채널에 새 토픽을 여십시오.
CI에서의 병렬 프런트엔드 테스트
CI 작업 test-x86_64-gnu-parallel-frontend에서 tests/ui의 테스트 실패를 발견하면, 여러분의 PR이 병렬 컴파일러나 그 테스트와 전혀 관련이 없더라도 실패한 테스트에 //@ ignore-parallel-frontend triage를 추가하십시오. 얼마 후 병렬 rustc 작업 그룹의 구성원들이 실패한 테스트를 분류(triage)하고, 이에 대한 추적 이슈를 만든 뒤 문제를 디버깅하고자 시도할 것입니다.
자세한 내용은 다음을 참고하십시오.
표준 라이브러리 유의적 버전 호환성 깨짐 검사
test-x86_64-gnu-stdlib-semver-check 작업은 의도하지 않은 유의적 버전(semver) 호환성 깨짐을 찾아내기 위해 표준 라이브러리(core, alloc, std)에 대해 cargo-semver-checks(c-s-c) 도구를 실행합니다. 이를 위해 부모 병합 커밋과 현재 병합되는 커밋의 rustdoc JSON 출력(rust-docs-json 컴포넌트에서 생성됨)을 분석합니다. 이 검사를 실행하면 다음 다섯 가지 중 하나가 일어날 수 있습니다.
- 모든 것이 정상적으로 진행되고, c-s-c가 호환성 깨짐을 전혀 발견하지 않습니다.
- 최근에 rustdoc JSON 버전이 올라갔는데 c-s-c가 아직 이를 처리하지 못합니다. 이 경우 테스트는 성공으로 끝나며, c-s-c를 업데이트해야 한다는 경고를 출력합니다. c-s-c가 새 JSON 형식을 지원하는 버전을 릴리스하면 다시 1번 상황으로 돌아갈 것입니다.
- 현재 이 작업에서는 c-s-c의 최신 릴리스 버전을 설치하므로,
rust-lang/rust저장소에서 버전을 수동으로 업데이트할 필요가 없습니다.
- 현재 이 작업에서는 c-s-c의 최신 릴리스 버전을 설치하므로,
- c-s-c가 호환성 문제를 감지했지만 거짓 양성인 경우입니다. 이 경우에는 거짓 양성을 이 Zulip 토픽에 보고하고 스탬프 파일을 갱신하십시오.
- c-s-c가 실제 호환성 깨짐을 감지했고, 덕분에 의도하지 않은 호환성 깨짐을 찾을 수 있었습니다. 잘됐습니다! 이 경우 이 Zulip 토픽에 성공 사례를 보고해 주시기 바랍니다.
- c-s-c가 실제 호환성 문제를 감지했지만 그래도 병합하려는 경우입니다(FCP를 거친 엣지 케이스일 수 있습니다). 이 경우 스탬프 파일을 갱신하십시오.
표준 라이브러리 semver 스탬프 파일 갱신하기
c-s-c가 호환성 깨짐을 감지한 PR에서 CI를 통과시키려면(실제 깨짐인지 여부와 관계없이) src/bootstrap/stdlib-semver-check-stamp 파일을 수정해야 합니다. 파일 맨 아래에 이 파일을 수정하는 PR 번호를 기입하십시오. 이렇게 하면 c-s-c가 무엇을 감지하든 테스트가 계속 통과 상태로 유지됩니다.
검사를 수동으로 실행하기
./x test std-semver-check --set rust.stdlib-semver-baseline=${PARENT}를 사용하여 로컬에서 semver 검사를 수동으로 실행할 수 있습니다. 여기서 PARENT는 트리 내 표준 라이브러리와 비교할 커밋 SHA입니다. 이를 지정하지 않으면 bootstrap이 로컬 git 기록에서 찾은 가장 최근의 업스트림 커밋을 선택합니다.
테스트 관련 기타 정보
RUSTC_BOOTSTRAP와 안정성
이것은 부트스트랩/컴파일러 구현 세부 사항이지만, 테스트에도 유용하게 쓰일 수 있습니다:
RUSTC_BOOTSTRAP=1은 “편법“을 써서 일반적인 안정성 검사를 우회하므로, 안정 버전rustc에서도 불안정 기능과 CLI 플래그를 사용할 수 있습니다.RUSTC_BOOTSTRAP=-1을 설정하면 해당rustc가 실제로는 nightlyrustc이더라도 안정 버전 컴파일러인 것처럼 동작하도록 강제합니다. 컴파일러가 nightly인지 아닌지에 따라 컴파일러의 일부 동작(예: 진단)이 달라질 수 있으므로 이 기능은 유용합니다.
ui 테스트와 //@ rustc-env를 지원하는 다른 테스트 스위트에서는 다음과 같이 지정할 수 있습니다
// Force unstable features to be usable on stable rustc
//@ rustc-env:RUSTC_BOOTSTRAP=1
// Or force nightly rustc to pretend it is a stable rustc
//@ rustc-env:RUSTC_BOOTSTRAP=-1
run-make/run-make-cargo 테스트에서는 //@ rustc-env가 지원되지 않습니다. 개별 rustc 호출에는 다음과 같은 방식을 사용할 수 있습니다.
use run_make_support::rustc;
fn main() {
rustc()
// Pretend that I am very stable
.env("RUSTC_BOOTSTRAP", "-1")
//...
.run();
}
컴파일러 디버깅
이 장에서는 컴파일러를 디버깅하는 몇 가지 팁을 소개합니다. 이 팁들은 어떤 작업을 하든 유용하도록 작성되었습니다. 다른 몇몇 장에서는 컴파일러의 특정 부분에 관한 조언을 다룹니다(예: 쿼리 디버깅 및 테스트 장이나 LLVM 디버깅 장).
컴파일러 구성하기
기본적으로 rustc는 디버그 정보를 대부분 포함하지 않은 채 빌드됩니다. 디버그 정보를 활성화하려면 bootstrap.toml에서 rust.debug = true를 설정하십시오.
rust.debug = true를 설정하면 여러 가지 디버그 옵션(예: debug-assertions, debug-logging 등)이 켜집니다. 원한다면 이 옵션들을 개별적으로 조정할 수 있지만, 많은 사람들은 그냥 rust.debug = true만 설정합니다.
GDB로 rustc를 디버깅하려면 bootstrap.toml에 다음 옵션을 설정하십시오:
rust.debug = true
rust.debuginfo-level = 2
NOTE: 이 작업은 디스크 공간을 많이( 35GB 이상) 사용하며 컴파일 시간도 훨씬 더 오래 걸립니다.
debuginfo-level = 1(debug = true일 때의 기본값)을 사용하면 실행 경로는 추적할 수 있지만, 디버깅에 필요한 심볼 정보는 잃게 됩니다.
기본 구성에서는 symbol-mangling-version v0이 활성화됩니다. 이를 사용하려면 GDB v10.2 이상이 필요하며, 그렇지 않으면 bootstrap.toml에서 새 symbol-mangling-version을 비활성화해야 합니다.
rust.new-symbol-mangling = false
자세한 내용은
bootstrap.example.toml의 주석을 참고하십시오.
구성 옵션을 변경한 후에는 컴파일러를 다시 빌드해야 합니다.
ICE 파일 생성 억제하기
기본적으로 rustc는 내부 컴파일러 오류(Internal Compiler Error, ICE)를 만나면 현재 작업 디렉터리에 rustc-ice-<timestamp>-<pid>.txt라는 이름의 ICE 파일을 만들고 ICE 내용을 덤프합니다. 이를 원하지 않는다면 RUSTC_ICE=0으로 ICE 파일이 생성되지 않도록 할 수 있습니다.
백트레이스 얻기
ICE(컴파일러 내부의 패닉)가 발생하면 일반 Rust 프로그램에서처럼 RUST_BACKTRACE=1을 설정하여 panic!의 스택 트레이스를 얻을 수 있습니다. 제 기억이 맞다면 MinGW에서는 백트레이스가 작동하지 않습니다. 죄송합니다. 문제가 있거나 백트레이스가 unknown으로 가득하다면 Linux나 Mac, 또는 Windows의 MSVC를 사용할 방법을 찾아보는 것이 좋습니다.
기본 구성(debug가 true로 설정되지 않은 상태)에서는 줄 번호가 활성화되어 있지 않으므로 백트레이스가 다음과 같이 표시됩니다:
stack backtrace:
0: std::sys::imp::backtrace::tracing::imp::unwind_backtrace
1: std::sys_common::backtrace::_print
2: std::panicking::default_hook::{{closure}}
3: std::panicking::default_hook
4: std::panicking::rust_panic_with_hook
5: std::panicking::begin_panic
(~~~~ LINES REMOVED BY ME FOR BREVITY ~~~~)
32: rustc_typeck::check_crate
33: <std::thread::local::LocalKey<T>>::with
34: <std::thread::local::LocalKey<T>>::with
35: rustc::ty::context::TyCtxt::create_and_enter
36: rustc_driver::driver::compile_input
37: rustc_driver::run_compiler
debug = true를 설정하면 스택 트레이스에 줄 번호가 표시됩니다. 그러면 백트레이스가 다음과 같이 표시됩니다:
stack backtrace:
(~~~~ LINES REMOVED BY ME FOR BREVITY ~~~~)
at /home/user/rust/compiler/rustc_typeck/src/check/cast.rs:110
7: rustc_typeck::check::cast::CastCheck::check
at /home/user/rust/compiler/rustc_typeck/src/check/cast.rs:572
at /home/user/rust/compiler/rustc_typeck/src/check/cast.rs:460
at /home/user/rust/compiler/rustc_typeck/src/check/cast.rs:370
(~~~~ LINES REMOVED BY ME FOR BREVITY ~~~~)
33: rustc_driver::driver::compile_input
at /home/user/rust/compiler/rustc_driver/src/driver.rs:1010
at /home/user/rust/compiler/rustc_driver/src/driver.rs:212
34: rustc_driver::run_compiler
at /home/user/rust/compiler/rustc_driver/src/lib.rs:253
-Z 플래그
컴파일러에는 다양한 -Z * 플래그가 있습니다. 이 플래그들은 nightly에서만 활성화되는 불안정 플래그입니다. 이 중 다수는 디버깅에 유용합니다. -Z 플래그의 전체 목록을 보려면 -Z help를 사용하십시오.
유용한 플래그 중 하나는 -Z verbose-internals로, 대체로 디버깅에 유용할 수 있는 정보를 더 많이 출력하도록 합니다.
바로 아래에서 그중 몇 가지를 골라 자세히 설명합니다.
오류의 백트레이스 얻기
컴파일러가 오류 메시지를 출력하는 지점까지의 백트레이스를 얻으려면 -Z treat-err-as-bug=n을 전달하면 됩니다. 이렇게 하면 컴파일러가 nth 오류에서 패닉합니다. =n을 생략하면 컴파일러는 n을 1로 간주하므로, 처음 만나는 오류에서 패닉합니다.
예를 들면 다음과 같습니다.
$ cat error.rs
fn main() {
1 + ();
}
$ rustc +stage1 error.rs
error[E0277]: cannot add `()` to `{integer}`
--> error.rs:2:7
|
2 | 1 + ();
| ^ no implementation for `{integer} + ()`
|
= help: the trait `Add<()>` is not implemented for `{integer}`
error: aborting due to previous error
그렇다면 위의 오류는 어디에서 발생한 것입니까?
$ RUST_BACKTRACE=1 rustc +stage1 error.rs -Z treat-err-as-bug
error[E0277]: the trait bound `{integer}: std::ops::Add<()>` is not satisfied
--> error.rs:2:7
|
2 | 1 + ();
| ^ no implementation for `{integer} + ()`
|
= help: the trait `std::ops::Add<()>` is not implemented for `{integer}`
error: internal compiler error: unexpected panic
note: the compiler unexpectedly panicked. this is a bug.
note: we would appreciate a bug report: https://github.com/rust-lang/rust/blob/HEAD/CONTRIBUTING.md#bug-reports
note: rustc 1.24.0-dev running on x86_64-unknown-linux-gnu
note: run with `RUST_BACKTRACE=1` for a backtrace
thread 'rustc' panicked at 'encountered error with `-Z treat_err_as_bug',
/home/user/rust/compiler/rustc_errors/src/lib.rs:411:12
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose
backtrace.
stack backtrace:
(~~~ IRRELEVANT PART OF BACKTRACE REMOVED BY ME ~~~)
7: rustc::traits::error_reporting::<impl rustc::infer::InferCtxt<'a, 'tcx>>
::report_selection_error
at /home/user/rust/compiler/rustc_middle/src/traits/error_reporting.rs:823
8: rustc::traits::error_reporting::<impl rustc::infer::InferCtxt<'a, 'tcx>>
::report_fulfillment_errors
at /home/user/rust/compiler/rustc_middle/src/traits/error_reporting.rs:160
at /home/user/rust/compiler/rustc_middle/src/traits/error_reporting.rs:112
9: rustc_typeck::check::FnCtxt::select_obligations_where_possible
at /home/user/rust/compiler/rustc_typeck/src/check/mod.rs:2192
(~~~ IRRELEVANT PART OF BACKTRACE REMOVED BY ME ~~~)
36: rustc_driver::run_compiler
at /home/user/rust/compiler/rustc_driver/src/lib.rs:253
좋습니다. 이제 오류의 백트레이스를 얻었습니다!
지연된 버그 디버깅하기
-Z eagerly-emit-delayed-bugs 옵션을 사용하면 지연된 버그를 쉽게 디버깅할 수 있습니다. 이 옵션은 지연된 버그를 일반 오류로 바꿉니다. 즉, 눈에 보이게 만듭니다. 이 옵션을 -Z treat-err-as-bug와 함께 사용하면 특정 지연된 버그에서 멈추고 백트레이스를 얻을 수 있습니다.
오류가 생성된 위치 알아내기
-Z track-diagnostics는 오류가 어디에서 출력되는지 알아내는 데 도움이 됩니다. 이 옵션은 이를 위해 #[track_caller]를 사용하며, 오류와 함께 그 위치를 출력합니다.
$ RUST_BACKTRACE=1 rustc +stage1 error.rs -Z track-diagnostics
error[E0277]: cannot add `()` to `{integer}`
--> src\error.rs:2:7
|
2 | 1 + ();
| ^ no implementation for `{integer} + ()`
-Ztrack-diagnostics: created at compiler/rustc_trait_selection/src/traits/error_reporting/mod.rs:638:39
|
= help: the trait `Add<()>` is not implemented for `{integer}`
= help: the following other types implement trait `Add<Rhs>`:
<&'a f32 as Add<f32>>
<&'a f64 as Add<f64>>
<&'a i128 as Add<i128>>
<&'a i16 as Add<i16>>
<&'a i32 as Add<i32>>
<&'a i64 as Add<i64>>
<&'a i8 as Add<i8>>
<&'a isize as Add<isize>>
and 48 others
For more information about this error, try `rustc --explain E0277`.
이는 -Z treat-err-as-bug와 비슷하지만 다음과 같은 차이가 있습니다.
- 출력된 모든 오류의 위치를 보여 줍니다
- 디버그 심볼을 포함해 빌드한 컴파일러가 필요하지 않습니다
- 긴 스택 트레이스를 일일이 읽을 필요가 없습니다.
로그 출력 얻기
컴파일러는 로깅에 tracing 크레이트를 사용합니다.
자세한 내용은 tracing에 관한 장을 참고하십시오.
회귀 범위 좁히기(이분 탐색)
cargo-bisect-rustc 도구를 사용하면 어떤 PR 때문에 rustc의 동작이 바뀌었는지 빠르고 쉽게 정확히 찾아낼 수 있습니다. 이 도구는 rustc PR 산출물을 자동으로 내려받아 회귀를 찾을 때까지 사용자가 제공한 프로젝트에 대해 테스트합니다. 그런 다음 해당 PR을 살펴보면 변경된 이유에 관한 맥락을 더 얻을 수 있습니다. 사용 방법은 이 튜토리얼을 참고하십시오.
Rust CI에서 산출물 내려받기
kennytm이 만든 rustup-toolchain-install-master 도구를 사용하면 Rust CI가 특정 SHA1에 대해 생성한 산출물을 다운로드한 뒤 로컬에서 사용할 수 있는 툴체인으로 설정할 수 있습니다. 이 SHA1은 기본적으로 어떤 PR이 성공적으로 병합된 시점에 해당합니다. 이 방법은 @bors try로 생성된 산출물에도 적용됩니다. 이 도구는 직접 빌드하지 않고 PR의 빌드 결과물을 살펴보고 싶을 때 유용합니다.
#[rustc_*] 테스트 속성
컴파일러는 수많은 내부(영구 불안정) 속성을 정의합니다. 그중 일부는 컴파일러 내부 정보를 추가로 덤프해 주므로 디버깅에 유용합니다. 이 속성들에는 rustc_ 접두사가 붙으며, 내부 기능 rustc_attrs로 게이트되어 있습니다(예: #![feature(rustc_attrs)]로 활성화합니다).
완전한 최신 목록은 builtin_attrs를 참고하십시오. 더 구체적으로는 TEST로 표시된 항목들입니다. 주목할 만한 몇 가지는 다음과 같습니다.
| 속성 | 설명 |
|---|---|
rustc_dump_def_parents | 특정 정의들의 DefId 부모 체인을 덤프합니다. |
rustc_dump_def_path | 항목의 def_path_str을 덤프합니다. |
rustc_dump_generics | 항목의 제네릭을 덤프합니다. |
rustc_dump_hidden_type_of_opaques | 크레이트에 있는 각 불투명 타입의 숨겨진 타입을 덤프합니다. |
rustc_dump_inferred_outlives | 항목의 암시적 바운드를 덤프합니다. 더 정확히 말하면, 항목의 inferred_outlives_of를 덤프합니다. |
rustc_dump_item_bounds | 항목의 item_bounds를 덤프합니다. |
rustc_dump_layout | 이 절을 참고하십시오. |
rustc_dump_object_lifetime_defaults | 항목의 객체 라이프타임 기본값을 덤프합니다. |
rustc_dump_clauses | 항목의 clauses_of를 덤프합니다. |
rustc_dump_symbol_name | 항목의 맹글링된 & 디맹글링된 symbol_name을 덤프합니다. |
rustc_dump_variances | 항목의 변성을 덤프합니다. |
rustc_dump_vtable | impl 또는 dyn 타입의 타입 별칭에 대한 vtable 레이아웃을 덤프합니다. |
rustc_regions | NLL 클로저 영역 요구 사항을 덤프합니다. |
바로 아래에서 그중 몇 가지에 대한 자세한 설명을 확인할 수 있습니다.
Graphviz 출력(.dot 파일) 서식 지정
특정 기능을 디버깅하기 위한 일부 컴파일러 옵션은 graphviz 그래프를 생성합니다. 예를 들어 함수에 #[rustc_mir(borrowck_graphviz_postflow="suffix.dot")] 속성을 붙이고 -Zdump-mir-dataflow와 함께 사용하면 다양한 빌림 검사기 데이터 흐름 그래프를 덤프합니다.
이 옵션들은 모두 .dot 파일을 생성합니다. 이 파일을 보려면 graphviz를 설치한 다음(예: apt-get install graphviz) 다음 명령을 실행하십시오.
$ dot -T pdf maybe_init_suffix.dot > maybe_init_suffix.pdf
$ firefox maybe_init_suffix.pdf # Or your favorite pdf viewer
Graphviz에는 전처리기 프로그램인 unflatten도 함께 제공되며, 이 프로그램은 출력이 이상하게 흩어져 보이는 현상을 줄이는 데 도움이 되기도 합니다. 이 프로그램은 dot 파일을 읽어 다른 dot 파일을 출력하므로, 다음과 같이 파이프에서 사용할 수 있습니다.
$ unflatten mir_dump/*.foo.-------.nll.0.regioncx.all.dot | dot -Tpdf -o foo-outlives.pdf
이는 빌림 검사기에서 나오는 복잡한 영역 outlives 그래프에 특히 유용합니다.
온라인 Graphviz 편집기 및 시각화 도구도 이용할 수 있습니다.
회귀 범위 좁히기(이분 탐색)
내부 속성 #[rustc_dump_layout(...)]를 사용하면 이 속성이 붙은 타입의 Layout을 덤프할 수 있습니다. 예를 들면 다음과 같습니다.
#![allow(unused)]
#![feature(rustc_attrs)]
fn main() {
#[rustc_dump_layout(debug)]
type T<'a> = &'a u32;
}
다음과 같이 출력합니다.
error: layout_of(&u32) = Layout {
size: Size(8 bytes),
align: AbiAlign {
abi: Align(8 bytes),
},
backend_repr: Scalar(
Initialized {
value: Pointer(
AddressSpace(
0,
),
),
valid_range: 1..=18446744073709551615,
},
),
fields: Primitive,
largest_niche: Some(
Niche {
offset: Size(0 bytes),
value: Pointer(
AddressSpace(
0,
),
),
valid_range: 1..=18446744073709551615,
},
),
uninhabited: false,
variants: Single {
index: 0,
},
max_repr_align: None,
unadjusted_abi_align: Align(8 bytes),
randomization_seed: 281492156579847,
}
--> src/lib.rs:4:1
|
4 | type T<'a> = &'a u32;
| ^^^^^^^^^^^^^^^^^^^^^
error: aborting due to previous error
빌림 검사 디버깅
빌림 검사기 디버깅은 별도의 장에서 다룹니다.
rustc 디버깅을 위한 CodeLLDB 구성
VSCode를 사용 중이고 관심 있는 코드 부분에 대해 디버깅 수준 1 또는 2를 요청하도록 bootstrap.toml을 수정했다면, VSCode의 CodeLLDB 확장을 사용하여 디버깅할 수 있습니다.
다음은 stage 1 컴파일러를 빌드된 디렉터리에서 직접 실행하는 데 사용하는 launch.json 파일의 예시입니다(“설치“할 필요는 없습니다).
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Launch",
"args": [], // array of string command-line arguments to pass to compiler
"program": "${workspaceFolder}/build/host/stage1/bin/rustc",
"windows": { // applicable if using windows
"program": "${workspaceFolder}/build/host/stage1/bin/rustc.exe"
},
"cwd": "${workspaceFolder}", // current working directory at program start
"stopOnEntry": false,
"sourceLanguages": ["rust"]
}
]
}
트레이싱을 사용하여 컴파일러 디버깅하기
컴파일러에는 debug!(또는 trace!) 호출이 많이 있으며, 이 호출들은 여러 지점에서 로깅 정보를 출력합니다. 이러한 호출은 버그를 완전히 찾아내지는 못하더라도 최소한 버그의 위치를 좁히는 데, 또는 단순히 컴파일러가 왜 특정한 동작을 하는지 파악하는 데 매우 유용합니다.
로그를 보려면 RUSTC_LOG 환경 변수를 원하는 로그 필터로 설정해야 합니다. 로그 필터의 전체 구문은 tracing-subscriber의 rustdoc에서 확인할 수 있습니다.
환경 변수
다음은 rustc가 트레이싱 출력을 사용자 지정하기 위해 받아들이는 환경 변수의 개요입니다. 이 변수들의 정의는 대부분 compiler/rustc_log/src/lib.rs에서 찾을 수 있습니다.
| 이름 | 용도 |
|---|---|
RUSTC_LOG | 트레이싱 필터(이 페이지의 나머지 부분을 참고하십시오) |
RUSTC_LOG_COLOR | always, never 또는 auto입니다. |
RUSTC_LOG_ENTRY_EXIT | 설정되어 있고 ’0’이 아니면 스팬 진입/종료를 로그로 남깁니다. |
RUSTC_LOG_THREAD_IDS | 설정되어 있고 ’1’과 같으면 스레드 ID도 함께 로그로 남깁니다. |
RUSTC_LOG_BACKTRACE | 제공된 문자열 값과 일치하는 대상으로 트레이스가 출력되면 백트레이스를 캡처하여 출력합니다. |
RUSTC_LOG_LINES | 1이면 깊이에 따라 로그 줄을 들여 씁니다. |
RUSTC_LOG_FORMAT_JSON | 1이면 로그를 JSON으로 출력합니다. 정확한 매개변수는 rustc_log/src/lib.rs에서 찾을 수 있지만, 이 형식은 불안정합니다. |
RUSTC_LOG_OUTPUT_TARGET | 이 변수를 지정하면 로그가 stderr 대신 지정한 파일 이름으로 출력됩니다. |
함수 수준 필터
rustc의 많은 함수에는 다음과 같은 어노테이션이 붙어 있습니다
#[instrument(level = "debug", skip(self))]
fn foo(&self, bar: Type) {}
이를 통해 다음을 사용하여
RUSTC_LOG=[foo]
아래 작업을 한꺼번에 수행할 수 있습니다
foo에 대한 모든 함수 호출을 로그로 남깁니다- 인자를 로그로 남깁니다(
skip목록에 있는 인자는 제외합니다). - 함수가 반환될 때까지 (컴파일러의 다른 어느 곳에서 발생하든) 모든 것을 로그로 남깁니다.
모든 것을 원하지는 않습니다
함수의 스코프에 따라서는 함수 본문의 모든 내용을 로그로 남기고 싶지 않을 수도 있습니다. 예를 들어, do_mir_borrowck 함수는 사소한 코드를 빌림 검사할 때에도 수백 줄을 출력합니다.
모든 필터를 조합할 수 있으므로, 예를 들어 다음과 같이 크레이트/모듈 경로를 추가할 수 있습니다.
RUSTC_LOG=rustc_borrowck[do_mir_borrowck]
모든 호출을 원하지는 않습니다
libcore를 컴파일하는 경우라면 모든 borrowck 덤프가 아니라 특정 함수 하나에 대한 덤프만 원할 가능성이 높습니다. 함수 호출의 인자에 정규식을 적용하여 함수 호출을 필터링할 수 있습니다.
RUSTC_LOG=[do_mir_borrowck{id=\.\*from_utf8_unchecked\.\*}]
위와 같이 하면 from_utf8_unchecked를 빌림 검사하는 로그만 얻을 수 있습니다. 무시된 do_mir_borrowck마다 짧은 메시지는 여전히 출력되지만, 해당 호출 내부의 내용은 전혀 출력되지 않는다는 점에 유의하십시오. 이는 실제로 일어나는 호출을 살펴보는 데 도움이 되며, 정규식을 잘못 입력한 경우 이를 수정하는 데에도 도움이 됩니다.
쿼리 수준 필터
모든 쿼리에는 로깅 스팬이 자동으로 태그되므로, 쿼리 실행 중의 모든 로그 메시지를 표시할 수 있습니다. 예를 들어, 타입 검사 중의 모든 것을 로그로 남기려면 다음과 같이 합니다.
RUSTC_LOG=[typeck]
쿼리 인자는 tracing 필드로 포함되므로, 인자의 디버그 표시를 기준으로 필터링할 수 있습니다. 예를 들어, typeck 쿼리에는 검사 대상을 나타내는 key: LocalDefId 인자가 있습니다. 정규식으로 해당 LocalDefId를 매칭하여 특정 함수의 타입 검사를 로그로 남길 수 있습니다.
RUSTC_LOG=[typeck{key=.*name_of_item.*}]
쿼리마다 인자가 다릅니다. 쿼리 목록과 각 쿼리의 인자는 rustc_middle/src/queries.rs에서 찾을 수 있습니다.
광범위한 모듈 수준 필터
log 크레이트의 필터와 비슷한 필터를 사용할 수도 있으며, 이 필터는 특정 모듈 내의 모든 것을 활성화합니다. 이 방식은 대개 출력이 너무 많고 구조화되어 있지 않으므로 함수 수준 필터를 사용할 것을 권장합니다.
로그 필터로 debug만 지정하면 모든 debug! 이상의 출력(예: info!도 포함됩니다)을 얻을 수 있고, path::to::module을 지정하면 특정 모듈의 모든 출력(trace! 포함)을 얻을 수 있으며, path::to::module=debug를 지정하면 특정 모듈의 debug! 이상의 출력을 얻을 수 있습니다.
예를 들어, 특정 모듈의 debug! 이상의 출력을 얻으려면 RUSTC_LOG=path::to::module=debug rustc my-file.rs로 컴파일러를 실행하면 됩니다. 그러면 모든 debug! 출력이 표준 오류에 나타납니다.
경로의 일부만 사용해도 필터가 여전히 동작한다는 점에 유의하십시오. 예를 들어, rustdoc::passes::collect_intra_doc_links의 info! 출력만 보고 싶다면 RUSTDOC_LOG=rustdoc::passes::collect_intra_doc_links=info를 사용할 수도 있고, 또는 RUSTDOC_LOG=rustdoc::passes::collect_intra=info를 사용할 수도 있습니다.
rustdoc을 개발하는 경우에는 대신 RUSTDOC_LOG를 사용하십시오. Miri를 개발하는 경우에는 대신 MIRI_LOG를 사용하십시오. 이제 감이 잡히실 것입니다 :)
사용할 수 있는 전체 구문을 확인하려면 tracing 크레이트의 문서, 특히 debug!에 대한 문서를 참고하십시오. (참고: 컴파일러와 달리 tracing 크레이트와 그 예제는 RUSTC_LOG 환경 변수를 사용합니다. rustc, rustdoc 및 기타 도구는 자체 환경 변수를 설정합니다.)
매우 엄격한 필터를 사용하지 않는 한 로거가 많은 출력을 내보낸다는 점에 유의하여, 가능한 한 가장 구체적인 모듈을 지정하십시오(여러 개라면 쉼표로 구분). 일반적으로 표준 에러를 파일로 파이프하고 텍스트 편집기로 로그 출력을 살펴보는 것이 좋습니다.
이를 종합하면 다음과 같습니다:
# This puts the output of all debug calls in `rustc_middle/src/traits` into
# standard error, which might fill your console backscroll.
$ RUSTC_LOG=rustc_middle::traits=debug rustc +stage1 my-file.rs
# This puts the output of all debug calls in `rustc_middle/src/traits` in
# `traits-log`, so you can then see it with a text editor.
$ RUSTC_LOG=rustc_middle::traits=debug rustc +stage1 my-file.rs 2>traits-log
# Not recommended! This will show the output of all `debug!` calls
# in the Rust compiler, and there are a *lot* of them, so it will be
# hard to find anything.
$ RUSTC_LOG=debug rustc +stage1 my-file.rs 2>all-log
# This will show the output of all `info!` calls in `rustc_codegen_ssa`.
#
# There's an `info!` statement in `codegen_instance` that outputs
# every function that is codegen'd. This is useful to find out
# which function triggers an LLVM assertion, and this is an `info!`
# log rather than a `debug!` log so it will work on the official
# compilers.
$ RUSTC_LOG=rustc_codegen_ssa=info rustc +stage1 my-file.rs
# This will show all logs in `rustc_codegen_ssa` and `rustc_resolve`.
$ RUSTC_LOG=rustc_codegen_ssa,rustc_resolve rustc +stage1 my-file.rs
# This will show the output of all `info!` calls made by rustdoc
# or any rustc library it calls.
$ RUSTDOC_LOG=info rustdoc +stage1 my-file.rs
# This will only show `debug!` calls made by rustdoc directly,
# not any `rustc*` crate.
$ RUSTDOC_LOG=rustdoc=debug rustdoc +stage1 my-file.rs
로그 색상
기본적으로 rustc(그리고 rustdoc이나 Miri 같은 다른 도구)는 로그 출력에 ANSI 색상을 언제 사용할지 알아서 판단합니다. 터미널로 출력하는 경우에는 색상을 사용하고, 파일로 출력하거나 다른 곳으로 파이프되는 경우에는 사용하지 않습니다. 하지만 매우 엄격한 필터를 사용하지 않는 한 터미널에서 로그 출력을 읽기 어려우므로, 출력을 less 같은 페이저로 파이프하고 싶을 수 있습니다. 그러나 그렇게 하면 색상이 전혀 표시되지 않아 찾고 있는 내용을 골라내기가 어렵습니다!
로그 출력에 색상을 사용할지 여부는 RUSTC_LOG_COLOR 환경 변수(rustdoc은 RUSTDOC_LOG_COLOR, Miri는 MIRI_LOG_COLOR 등)로 재정의할 수 있습니다. 옵션은 auto(기본값), always, never 세 가지입니다. 따라서 less로 파이프할 때 색상을 활성화하려면 다음과 비슷한 명령을 사용하십시오:
# The `-R` switch tells less to print ANSI colors without escaping them.
$ RUSTC_LOG=debug RUSTC_LOG_COLOR=always rustc +stage1 ... | less -R
MIRI_LOG_COLOR는 Miri에서 나온 로그에만 색상을 입히며, Miri가 호출하는 rustc 함수의 로그에는 색상을 입히지 않는다는 점에 유의하십시오. rustc의 로그에 색상을 입히려면 RUSTC_LOG_COLOR를 사용하십시오.
결과 바이너리에서 debug! 및 trace! 호출을 유지하거나 제거하는 방법
error!, warn!, info! 호출은 컴파일러의 모든 빌드에 포함되지만, debug!와 trace! 호출은 bootstrap.toml에서 rust.debug-logging=true가 켜져 있는 경우에만 프로그램에 포함됩니다(기본값은 꺼져 있습니다). 따라서 DEBUG 로그가 보이지 않는다면, 특히 RUSTC_LOG=rustc rustc some.rs로 컴파일러를 실행했는데 INFO 로그만 보인다면, bootstrap.toml에서 rust.debug-logging=true가 켜져 있는지 확인하십시오.
로깅 에티켓과 관례
debug! 호출은 기본적으로 제거되므로, 대부분의 경우 “불필요한” debug! 호출을 추가하고 커밋하는 코드에 남겨 두더라도 성능은 걱정하지 않아도 됩니다. 이러한 호출은 우리가 배포하는 결과물의 성능을 떨어뜨리지 않습니다.
그렇기는 하지만 트레이싱 호출이 과도해질 수도 있으며, 특히 근처의 다른 호출이나 여기서 호출되는 함수 안의 호출과 중복되는 경우가 그렇습니다. 여기에는 완벽한 균형점이 없으며, debug! 문을 남겨 두도록 허용할지, 아니면 병합 전에 제거하도록 요청할지는 리뷰어의 재량에 맡겨집니다.
출력이 매우 많은 로그에는 debug!보다 trace!를 사용하는 것이 더 나을 수 있습니다.
느슨하게 지켜지는 관례로, 함수 foo의 시작 부분에서는 debug!("foo(...)") 대신 #[instrument(level = "debug")](이 속성의 문서도 참고하십시오)를 사용합니다. 함수 내부에서는 debug!("xyz = {:?}", variable.field)보다 debug!(?variable.field)를, debug!("bar = {:?}", var.method(arg))보다 debug!(bar = ?var.method(arg))를 사용하는 것이 좋습니다. 이 구문에 대한 문서는 여기에서 확인할 수 있습니다.
한 가지 주의해야 할 점은 로그 안의 비용이 큰 연산입니다.
rustc::foo 모듈에 다음과 같은 문이 있다고 합시다
debug!(x = ?random_operation(tcx));
그러면 누군가 RUSTC_LOG=rustc::foo로 디버그 rustc를 실행할 때 random_operation()이 실행됩니다. 이 디버그 문을 활성화하지 않는 RUSTC_LOG 필터는 random_operation을 실행하지 않습니다.
따라서 그곳에는 비용이 너무 크거나 충돌을 일으킬 가능성이 있는 것을 넣지 마십시오. 그렇게 하면 해당 모듈에 로깅을 사용하려는 모든 사람에게 불편을 주게 됩니다. 누군가 로깅을 사용해 다른 버그를 찾으려 하기 전까지는 아무도 그 사실을 모를 것입니다.
컴파일러 프로파일링
이 절에서는 컴파일러를 프로파일링하여 컴파일러가 어디에서 시간을 소비하는지 알아내는 방법을 설명합니다.
측정하려는 대상에 따라 여러 가지 접근 방식이 있습니다.
-
PR이 컴파일러 성능을 개선하는지 저하시키는지 확인하려면, 벤치마크 실행을 요청하는 방법을 다루는 rustc-perf 장을 참고하십시오.
-
rustc가 어디에서 시간을 소비하는지 중간 수준에서 높은 수준까지의 개요를 보려면 다음을 참고하십시오. -
함수 수준의 성능 데이터가 필요하거나 위의 접근 방식보다 더 자세한 정보가 필요하다면 다음을 참고하십시오.
-
크레이트 그래프의 컴파일 시간을 보기 좋게 시각화하려면 cargo의
--timings플래그를 사용할 수 있습니다. 예를 들면cargo build --timings와 같습니다../x build compiler --timings를 사용하면 컴파일러 자체에도 이 플래그를 사용할 수 있습니다. -
메모리 사용량을 프로파일링하려면 사용 중인 운영 체제에 따라 다양한 도구를 사용할 수 있습니다.
- Windows의 경우 WPA 가이드를 읽어 보십시오.
cargo-llvm-lines로 rustc의 부트스트랩 시간 최적화하기
cargo-llvm-lines를 사용하면 제네릭 함수의 모든 인스턴스화에 걸친 LLVM IR 줄 수를 셀 수 있습니다. rustc 컴파일 시간의 대부분이 LLVM에서 소비되므로, LLVM에 전달되는 코드의 양을 줄이면 rustc 컴파일이 빨라진다는 것이 기본 발상입니다.
다소 독자적인 rustc 빌드 과정과 함께 cargo-llvm-lines를 사용하려면 -C save-temps를 사용하여 필요한 LLVM IR을 얻을 수 있습니다. 이 옵션은 컴파일 중에 생성된 임시 작업 산출물을 보존합니다. 그중에는 최적화 파이프라인의 입력을 나타내는 LLVM IR이 있으며, 이는 우리의 목적에 이상적입니다. 이는 LLVM 비트코드 형식으로 *.no-opt.bc 확장자를 가진 파일에 저장됩니다.
사용 예시:
cargo install --locked cargo-llvm-lines
# On a normal crate you could now run `cargo llvm-lines`, but `x` isn't normal :P
# Do a clean before every run, to not mix in the results from previous runs.
./x clean
env RUSTFLAGS=-Csave-temps ./x build --stage 0 compiler/rustc
# Single crate, e.g., rustc_middle. (Relies on the glob support of your shell.)
# Convert unoptimized LLVM bitcode into a human readable LLVM assembly accepted by cargo-llvm-lines.
for f in build/x86_64-unknown-linux-gnu/stage0-rustc/x86_64-unknown-linux-gnu/release/deps/rustc_middle-*.no-opt.bc; do
./build/x86_64-unknown-linux-gnu/llvm/bin/llvm-dis "$f"
done
cargo llvm-lines --files ./build/x86_64-unknown-linux-gnu/stage0-rustc/x86_64-unknown-linux-gnu/release/deps/rustc_middle-*.ll > llvm-lines-middle.txt
# Specify all crates of the compiler.
for f in build/x86_64-unknown-linux-gnu/stage0-rustc/x86_64-unknown-linux-gnu/release/deps/*.no-opt.bc; do
./build/x86_64-unknown-linux-gnu/llvm/bin/llvm-dis "$f"
done
cargo llvm-lines --files ./build/x86_64-unknown-linux-gnu/stage0-rustc/x86_64-unknown-linux-gnu/release/deps/*.ll > llvm-lines.txt
컴파일러에 대한 출력 예시:
Lines Copies Function name
----- ------ -------------
45207720 (100%) 1583774 (100%) (TOTAL)
2102350 (4.7%) 146650 (9.3%) core::ptr::drop_in_place
615080 (1.4%) 8392 (0.5%) std::thread::local::LocalKey<T>::try_with
594296 (1.3%) 1780 (0.1%) hashbrown::raw::RawTable<T>::rehash_in_place
592071 (1.3%) 9691 (0.6%) core::option::Option<T>::map
528172 (1.2%) 5741 (0.4%) core::alloc::layout::Layout::array
466854 (1.0%) 8863 (0.6%) core::ptr::swap_nonoverlapping_one
412736 (0.9%) 1780 (0.1%) hashbrown::raw::RawTable<T>::resize
367776 (0.8%) 2554 (0.2%) alloc::raw_vec::RawVec<T,A>::grow_amortized
367507 (0.8%) 643 (0.0%) rustc_query_system::dep_graph::graph::DepGraph<K>::with_task_impl
355882 (0.8%) 6332 (0.4%) alloc::alloc::box_free
354556 (0.8%) 14213 (0.9%) core::ptr::write
354361 (0.8%) 3590 (0.2%) core::iter::traits::iterator::Iterator::fold
347761 (0.8%) 3873 (0.2%) rustc_middle::ty::context::tls::set_tlv
337534 (0.7%) 2377 (0.2%) alloc::raw_vec::RawVec<T,A>::allocate_in
331690 (0.7%) 3192 (0.2%) hashbrown::raw::RawTable<T>::find
328756 (0.7%) 3978 (0.3%) rustc_middle::ty::context::tls::with_context_opt
326903 (0.7%) 642 (0.0%) rustc_query_system::query::plumbing::try_execute_query
이 방법은 증분 컴파일이나 ./x check와 함께 동작하지 않는 것으로 보이므로, rustc를 아주 많이 컴파일하게 될 것입니다. 견딜 만하도록 bootstrap.toml에서 몇 가지 설정을 변경할 것을 권장합니다.
# A debug build takes _a third_ as long on my machine,
# but compiling more than stage0 rustc becomes unbearably slow.
rust.optimize = false
# We can't use incremental anyway, so we disable it for a little speed boost.
rust.incremental = false
# We won't be running it, so no point in compiling debug checks.
rust.debug = false
# Using a single codegen unit gives less output, but is slower to compile.
rust.codegen-units = 0 # num_cpus
llvm-lines 출력은 여러 옵션의 영향을 받습니다. rust.optimize = false는 출력을 2.1GB에서 3.5GB로 늘리고, codegen-units = 0은 4.1GB로 늘립니다.
MIR 최적화는 영향이 거의 없습니다. 기본값인 RUSTFLAGS="-Z mir-opt-level=1"과 비교하면 레벨 0은 0.3GB를 늘리고 레벨 2는 0.2GB를 줄입니다. 2022년 7월 기준으로 인라이닝은 LLVM 및 GCC 코드 생성 백엔드에서 이루어지며, Cranelift 백엔드에서만 이루어지지 않습니다.
perf로 프로파일링하기
이 문서는 perf로 rustc를 프로파일링하는 방법을 안내합니다.
초기 단계
- rust-lang/rust를 깨끗한 상태로 체크아웃하십시오
bootstrap.toml에서 다음과 같이 설정하십시오:rust.debuginfo-level = 1- 줄 단위 디버그 정보를 활성화합니다build.allocator는 설정하지 않은 채로 두십시오 - 그래야 valgrind로 메모리 사용량을 프로파일링할 수 있습니다- 나머지는 모두 기본값으로 두십시오
./x build를 실행하여 전체 빌드를 수행하십시오- 빌드 결과물을 가리키는 rustup 툴체인을 만드십시오
perf 프로파일 수집하기
perf는 Linux에서 온갖 종류의 정보를 수집하고 분석하는 데 사용할 수 있는 훌륭한 도구입니다. 주로 프로그램이 어디에서 시간을 소비하는지 알아내는 데 사용됩니다. 하지만 캐시 미스 등 다른 종류의 이벤트에도 사용할 수 있습니다.
기본 사항
기본 perf 명령은 다음과 같습니다:
perf record -F99 --call-graph dwarf XXX
-F99는 perf가 99 Hz로 샘플링하도록 지시합니다. 이렇게 하면 오래 실행할 때 데이터가 지나치게 많이 생성되지 않습니다.
99 Hz를 자주 사용하는 이유는 다른 주기적인 활동과 주기가 맞물릴 가능성이 낮기 때문입니다.
--call-graph dwarf는 perf가 디버그 정보에서 호출 그래프 정보를 가져오도록 지시하며, 이 방식은 정확합니다. XXX는 프로파일링하려는 명령입니다. 예를 들어 다음과 같이 할 수 있습니다:
perf record -F99 --call-graph dwarf cargo +<toolchain> rustc
이렇게 하면 cargo가 실행됩니다. 여기서 <toolchain>은 처음에 만든 툴체인의 이름이어야 합니다. 다만 주의해야 할 점이 몇 가지 있습니다:
- 의존성을 빌드하는 데 걸린 시간까지 프로파일링하고 싶지는 않을 것입니다. 따라서
cargo build; cargo clean --package $crate와 같은 방법이 도움이 될 수 있습니다- 다만 저는 보통 그냥
touch src/lib.rs를 실행한 뒤 다시 빌드합니다 =)
- 다만 저는 보통 그냥
- 증분 컴파일이 프로파일을 어지럽히는 것도 원하지 않을 것입니다. 따라서
CARGO_INCREMENTAL=0과 같은 설정이 도움이 될 수 있습니다.
cargo에서 수집한 데이터를 읽을 때 addr2line xxx/elf: could not read first record 문제가 생기지 않게 하려면 최신 버전의 addr2line을 사용해야 할 수도 있습니다:
cargo install --locked addr2line --features="bin"
perf.rust-lang.org 테스트에서 perf 프로파일 수집하기
우리는 종종 perf.rust-lang.org의 특정 테스트를 분석해야 할 때가 있습니다. 가장 쉬운 방법은 rustc-perf 벤치마킹 도구 모음을 사용하는 것이며, 이 방법은 여기에 설명되어 있습니다.
벤치마크 도구 모음 CLI를 사용하는 대신 벤치마크를 수동으로 프로파일링할 수도 있습니다. 먼저 rustc-perf 저장소를 클론해야 합니다:
$ git clone https://github.com/rust-lang/rustc-perf
그런 다음 프로파일링하려는 테스트의 소스 코드를 찾으십시오. 테스트 소스는 collector/compile-benchmarks 디렉터리와 collector/runtime-benchmarks 디렉터리에 있습니다. 그러면 특정 테스트의 디렉터리로 들어가 봅시다. 여기서는 clap-rs를 예로 사용하겠습니다:
cd collector/compile-benchmarks/clap-3.1.6
여기서는 cargo check 성능을 프로파일링하려 한다고 가정해 봅시다. 이 경우 저는 먼저 의존성을 빌드하기 위해 몇 가지 기본 명령을 실행합니다:
# Setup: first clean out any old results and build the dependencies:
cargo +<toolchain> clean
CARGO_INCREMENTAL=0 cargo +<toolchain> check
(여기서도 <toolchain>은 첫 번째 단계에서 만든 툴체인의 이름으로 바꾸어야 합니다.)
다음으로, cargo check를 실행하면서 clap-rs 크레이트만의 실행 시간을 기록하려 합니다. 저는 이를 위해 주로 cargo rustc를 사용하는데, 명시적인 플래그도 추가할 수 있기 때문입니다. 이는 나중에 해 볼 것입니다.
touch src/lib.rs
CARGO_INCREMENTAL=0 perf record -F99 --call-graph dwarf cargo rustc --profile check --lib
마지막 명령을 주목하십시오. 상당히 복잡합니다! 이 명령은 (경우에 따라) 추가 옵션과 함께 rustc를 실행하는 cargo rustc 명령을 사용합니다. --profile check와 --lib 옵션은 cargo check 실행을 수행한다는 것과 대상이 (바이너리가 아닌) 라이브러리라는 것을 지정합니다.
이제 perf 도구를 사용하여 결과를 분석할 수 있습니다. 예를 들면 다음과 같습니다.
perf report
이 명령은 대화형 TUI 프로그램을 엽니다. 간단한 경우에는 이것이 유용할 수 있습니다. 더 자세히 살펴보려면 perf-focus 도구가 유용할 수 있으며, 이 도구는 아래에서 다룹니다.
주의 사항. rustc-perf 테스트는 저마다 고유한 특성을 지니고 있습니다. 특히 일부는 라이브러리가 아니며, 이 경우에는 touch src/main.rs를 실행하고 --lib를 전달하지 않아야 합니다. 솔직히 말해 어떤 테스트가 어떤 종류인지 가장 잘 구별하는 방법은 저도 잘 모르겠습니다.
NLL 데이터 수집하기
NLL 실행을 프로파일링하려면 다음과 같이 cargo rustc 명령에 추가 옵션을 전달하기만 하면 됩니다:
touch src/lib.rs
CARGO_INCREMENTAL=0 perf record -F99 --call-graph dwarf cargo rustc --profile check --lib -- -Z borrowck=mir
perf focus로 perf 프로파일 분석하기
perf 프로파일을 수집했다면 이제 그에 대한 정보를 얻어야 합니다. 이를 위해 저는 개인적으로 perf focus를 사용합니다. 이는 단순하지만 유용한 도구로, 다음과 같은 쿼리에 답할 수 있게 해 줍니다:
- “함수 F에서 얼마나 많은 시간이 소요되었는가” (어디에서 호출되었는지와 관계없이)
- “함수 F가 G에서 호출되었을 때 F에서 얼마나 많은 시간이 소요되었는가”
- “G에서 소요된 시간을 제외하고 함수 F에서 얼마나 많은 시간이 소요되었는가”
- “F는 어떤 함수들을 호출하며, 그 함수들에서 얼마나 많은 시간을 소비하는가”
작동 방식을 이해하려면 perf에 대해 조금 알아야 합니다. 기본적으로 perf는 프로세스를 주기적으로(또는 어떤 이벤트가 발생할 때마다) 샘플링하는 방식으로 작동합니다. perf는 각 샘플마다 백트레이스를 수집합니다. perf focus를 사용하면 해당 백트레이스에 어떤 함수가 나타나는지 테스트하는 정규 표현식을 작성할 수 있으며, 그 정규 표현식을 만족하는 백트레이스를 가진 샘플이 몇 퍼센트인지 알려 줍니다. 제가 NLL 성능을 분석하는 과정을 차례로 따라가며 설명하는 것이 아마 가장 쉬울 것입니다.
perf-focus 설치하기
cargo install을 사용하여 perf-focus를 설치할 수 있습니다:
cargo install --locked perf-focus
예시: MIR borrowck에서 얼마나 많은 시간이 소요됩니까?
어떤 테스트에 대한 NLL 데이터를 수집했다고 가정해 봅시다. 이 테스트가 MIR 빌림 검사기에서 얼마나 많은 시간을 소비하는지 알고 싶습니다. MIR borrowck의 “main” 함수는 do_mir_borrowck이므로 다음 명령을 실행할 수 있습니다.
$ perf focus '{do_mir_borrowck}'
Matcher : {do_mir_borrowck}
Matches : 228
Not Matches: 542
Percentage : 29%
'{do_mir_borrowck}' 인자를 매처라고 합니다. 매처는 백트레이스에 적용할 테스트를 지정합니다. 이 경우 {X}는 백트레이스에 정규 표현식 X를 만족하는 함수가 하나라도 있어야 함을 나타냅니다. 이 경우 해당 정규식은 단지 우리가 원하는 함수의 이름일 뿐입니다(사실 이름의 일부이며, 전체 이름에는 모듈 경로 등 여러 가지가 더 포함됩니다). 이 모드에서 perf-focus는 do_mir_borrowck가 스택에 있었던 샘플의 비율만 출력합니다. 이 경우에는 29%입니다.
c++filt에 관한 참고 사항. perf에서 데이터를 가져오기 위해 perf focus는 현재 perf script를 실행합니다(아마 더 나은 방법이 있을 것입니다…). 저는 가끔 perf script가 C++ 맹글링된 이름을 출력하는 경우를 보았습니다. 이는 번거로운 일입니다. 직접 perf script | head를 실행해 보면 알 수 있습니다. rustc::middle 대신 5rustc6middle 같은 이름이 보인다면 같은 문제를 겪고 있는 것입니다. 다음과 같이 하면 이 문제를 해결할 수 있습니다:
perf script | c++filt | perf focus --from-stdin ...
이렇게 하면 perf script의 출력이 파이프를 통해 c++filt를 거치게 되며, 대부분의 이름이 더 읽기 쉬운 형식으로 변환될 것입니다. perf focus의 --from-stdin 플래그는 perf focus를 실행하는 대신 stdin에서 데이터를 가져오도록 지시합니다. 이를 더 편리하게 만들어야 합니다(최소한 perf focus에 c++filt 옵션을 추가하거나, 아예 항상 사용하도록 할 수도 있습니다. 그렇게 해도 거의 해가 없습니다).
예시: MIR borrowck는 트레이트를 해결하는 데 얼마나 많은 시간을 소비합니까?
MIR borrowck가 트레이트 검사기에서 얼마나 많은 시간을 소비하는지 알고 싶을 수도 있습니다. 더 복잡한 정규식을 사용하여 이를 질의할 수 있습니다.
$ perf focus '{do_mir_borrowck}..{^rustc::traits}'
Matcher : {do_mir_borrowck},..{^rustc::traits}
Matches : 12
Not Matches: 1311
Percentage : 0%
여기서는 .. 연산자를 사용하여 “스택에 do_mir_borrowck가 있고, 그보다 나중에 이름이 rustc::traits로 시작하는 함수가 있는 경우가 얼마나 자주 있는가?”(기본적으로 해당 모듈의 코드)를 물었습니다. 결과적으로 답은 “거의 없음“입니다. 이 조건에 맞는 샘플은 12개뿐입니다(샘플이 하나도 보이지 않는다면, 대개 쿼리가 잘못되었다는 뜻입니다).
궁금하다면 --print-match 옵션을 사용하여 정확히 어떤 샘플인지 확인할 수 있습니다. 이 옵션은 각 샘플의 전체 백트레이스를 출력합니다. 줄 앞의 |는 정규 표현식과 일치한 부분을 나타냅니다.
예시: MIR borrowck는 어디에서 시간을 소비합니까?
좀 더 “탐색적인” 쿼리를 수행하고 싶을 때가 많습니다. 예를 들어, MIR borrowck가 전체 시간의 29%를 차지한다는 것은 알지만, 그 시간은 어디에서 소비되는 것입니까? 이럴 때는 대개 --tree-callees 옵션이 가장 좋은 도구입니다. 보통은 --tree-min-percent나 --tree-max-depth도 함께 지정하는 것이 좋습니다. 결과는 다음과 같습니다.
$ perf focus '{do_mir_borrowck}' --tree-callees --tree-min-percent 3
Matcher : {do_mir_borrowck}
Matches : 577
Not Matches: 746
Percentage : 43%
Tree
| matched `{do_mir_borrowck}` (43% total, 0% self)
: | rustc_borrowck::nll::compute_regions (20% total, 0% self)
: : | rustc_borrowck::nll::type_check::type_check_internal (13% total, 0% self)
: : : | core::ops::function::FnOnce::call_once (5% total, 0% self)
: : : : | rustc_borrowck::nll::type_check::liveness::generate (5% total, 3% self)
: : : | <rustc_borrowck::nll::type_check::TypeVerifier<'a, 'b, 'tcx> as rustc::mir::visit::Visitor<'tcx>>::visit_mir (3% total, 0% self)
: | rustc::mir::visit::Visitor::visit_mir (8% total, 6% self)
: | <rustc_borrowck::MirBorrowckCtxt<'cx, 'tcx> as rustc_mir_dataflow::DataflowResultsConsumer<'cx, 'tcx>>::visit_statement_entry (5% total, 0% self)
: | rustc_mir_dataflow::do_dataflow (3% total, 0% self)
--tree-callees를 사용하면 다음과 같은 일이 일어납니다.
- 정규 표현식과 일치하는 각 샘플을 찾습니다.
- 정규식 매치 이후에 나타나는 코드를 살펴보고 호출 트리를 빌드하려고 합니다
--tree-min-percent 3 옵션은 “전체 시간의 3%보다 많은 시간을 차지하는 것만 보여 달라“는 뜻입니다. 이 옵션이 없으면 트리가 매우 지저분해지는 경우가 많고, malloc 내부 같은 잡다한 항목까지 포함됩니다. --tree-max-depth도 유용할 수 있는데, 이 옵션은 단지 출력할 단계의 수를 제한합니다.
각 줄에는 해당 함수에서 소비된 전체 시간의 비율(“total”)과 그 함수가 호출한 다른 함수가 아닌 그 함수 자체에서만 소비된 시간의 비율(self)을 표시합니다. 보통은 “total“이 더 흥미로운 수치이지만, 항상 그런 것은 아닙니다.
상대 백분율
기본적으로 perf-focus의 모든 백분율은 전체 프로그램 실행을 기준으로 합니다. 이는 전체적인 관점을 유지하는 데 도움이 됩니다. 핫스팟을 찾기 위해 깊이 파고들다 보면, 전체 프로그램 실행 측면에서 이 “핫스팟“이 실제로는 중요하지 않다는 사실을 놓치기 쉽습니다. 또한 서로 다른 쿼리 간의 백분율을 쉽게 비교할 수 있게 해 줍니다.
그렇긴 하지만 때로는 상대 백분율을 얻는 것이 유용하므로, perf focus는 --relative 옵션을 제공합니다. 이 경우 백분율은 (전체 샘플이 아니라) 일치하는 샘플만을 기준으로 표시됩니다. 예를 들어 다음과 같이 borrowck 자체를 기준으로 한 백분율을 얻을 수 있습니다.
$ perf focus '{do_mir_borrowck}' --tree-callees --relative --tree-max-depth 1 --tree-min-percent 5
Matcher : {do_mir_borrowck}
Matches : 577
Not Matches: 746
Percentage : 100%
Tree
| matched `{do_mir_borrowck}` (100% total, 0% self)
: | rustc_borrowck::nll::compute_regions (47% total, 0% self) [...]
: | rustc::mir::visit::Visitor::visit_mir (19% total, 15% self) [...]
: | <rustc_borrowck::MirBorrowckCtxt<'cx, 'tcx> as rustc_mir_dataflow::DataflowResultsConsumer<'cx, 'tcx>>::visit_statement_entry (13% total, 0% self) [...]
: | rustc_mir_dataflow::do_dataflow (8% total, 1% self) [...]
여기서 compute_regions가 “47% total“로 나타난 것을 볼 수 있는데, 이는 do_mir_borrowck에서 소비된 시간의 47%가 해당 함수에서 소비된다는 뜻입니다. 앞에서는 20%로 나타났는데, 이는 do_mir_borrowck 자체가 전체 시간의 43%에 불과하기 때문입니다(그리고 .47 * .43 = .20입니다).
Windows에서 프로파일링하기
WPR과 WPA 소개
메모리 사용량을 포함한 고수준 성능 분석은 Windows Performance Recorder(WPR)와 Windows Performance Analyzer(WPA)로 수행할 수 있습니다. 이름에서 알 수 있듯이 WPR은 시스템 통계를 (이벤트 추적 로그, 즉 ETL 파일 형태로) 기록하는 도구이고, WPA는 이 ETL 파일을 분석하는 도구입니다.
WPR은 시스템 전체의 통계를 수집하므로 rustc와 관련된 것뿐만 아니라 머신에서 실행 중인 다른 모든 것도 기록합니다. 분석할 때 관심 있는 항목만 골라 필터링할 수 있습니다.
이 도구들은 상당히 강력하지만, Rust 컴파일러를 제대로 프로파일링하려면 먼저 사용법을 어느 정도 익혀야 합니다.
여기서는 WPR과 WPA를 사용하여 Rust 컴파일러를 분석하는 방법을 살펴보고, rustc 분석을 더 쉽게 하도록 특별히 설계된 유용한 “프로필”(즉, WPR과 WPA의 기본값을 조정하는 설정 파일)의 링크도 제공합니다.
WPR과 WPA 설치하기
WPR과 WPA는 Windows Performance Toolkit의 일부로 설치할 수 있으며, Windows Performance Toolkit은 Windows Assessment and Deployment Kit(ADK)를 다운로드할 때 선택할 수 있는 옵션 중 하나입니다. ADK 설치 프로그램은 여기에서 다운로드할 수 있습니다. 반드시 Windows Performance Toolkit을 선택하십시오(다른 항목은 선택하지 않아도 됩니다).
기록하기
시스템 분석을 수행하려면 먼저 WPR로 시스템을 기록해야 합니다. WPR을 열고 창 하단에서 기록하려는 항목의 “프로필“을 선택하십시오. rustc 부트스트랩 과정의 메모리 사용량을 살펴보려면 다음 항목을 선택해야 합니다:
- CPU 사용량
- VirtualAlloc 사용량
“Heap usage“도 함께 기록하고 싶을 수 있지만, 이 항목은 모든 힙 할당을 하나하나 기록하므로 비용이 매우, 매우 클 수 있습니다. 고수준 분석에서는 이 항목을 꺼 두는 것이 가장 좋을 것입니다.
이제 기록할 수 있도록 환경을 준비해야 합니다. 메모리 사용량을 분석할 때는 디버그 심볼이 포함된 stage 1 컴파일러 빌드로 stage 2 컴파일러를 빌드하는 과정을 기록하는 것이 가장 좋습니다. rustc를 빌드하는 데 사용하는 컴파일러에 심볼이 있으면 WPA가 Rust 심볼을 올바르게 해석할 수 있으므로 분석에 큰 도움이 됩니다. 안타깝게도 stage 0 컴파일러에는 심볼이 활성화되어 있지 않으므로, stage 1 컴파일러를 빌드한 다음 stage 2 컴파일러를 직접 빌드해야 합니다.
이를 위해 bootstrap.toml 파일에 rust.debuginfo-level = 1을 설정했는지 확인하십시오. 이는 rustc가 부트스트래핑 시 스택 프레임을 포함하는 디버그 정보를 생성하도록 지시합니다.
이제 stage 1 컴파일러를 빌드할 수 있습니다: x build --stage 1 -i library 또는 원하는 다른 방식으로 stage 1 컴파일러를 빌드하십시오.
stage 1 컴파일러가 빌드되었으므로 이제 stage 2 빌드를 기록할 수 있습니다. WPR로 돌아가 “start” 버튼을 클릭하고 stage 2 컴파일러를 빌드하십시오(예: x build --stage=2 -i library). 이 과정이 끝나면 기록을 중지하십시오.
Save 버튼을 클릭하고, 저장이 완료되면 나타나는 “Open in WPA” 버튼을 클릭하십시오.
참고: 트레이스 파일이 상당히 크기 때문에 WPA가 파일을 모두 여는 데 시간이 다소 걸릴 수 있습니다.
분석
이제 ETL 파일이 WPA에서 열렸으므로 결과를 분석할 수 있습니다. 먼저 미리 만들어 둔 “프로필“을 적용하여 WPA를 rustc 부트스트랩 분석에 적합한 상태로 만듭니다. 프로필은 여기에서 내려받으십시오. 상단의 “Profiles” 메뉴를 선택한 다음 “apply“를 선택하고, 내려받은 프로필을 고르십시오.
다음과 비슷한 화면이 보일 것입니다.
다음으로, WPA가 Rust 스택 트레이스를 올바르게 디맹글할 수 있도록 디버그 심볼을 불러와 처리하게 해야 합니다. 이를 위해 “Trace“를 클릭한 다음 “Load Symbols“를 선택하십시오. 이 단계는 시간이 꽤 걸릴 수 있습니다.
WPA가 rustc의 심볼을 불러오고 나면 rustc.exe 노드를 펼쳐 할당량이 가장 큰 스택부터 파고들기 시작할 수 있습니다.
이를 위해 “Commit Stack”(커밋 스택) 열에서 [Root] 노드를 펼치고, 흥미로운 스택 프레임을 찾을 때까지 계속 펼쳐 나가겠습니다.
팁: 펼치려는 노드를 선택한 후 오른쪽 화살표 키를 누르십시오. 그러면 노드가 펼쳐지고, 펼쳐진 항목 중 그다음으로 큰 노드가 선택됩니다. 흥미로운 프레임에 도달할 때까지 오른쪽 화살표 키를 계속 누르면 됩니다.
이 샘플에서는 코드 생성을 거치는 호출들이 이 프로필 전체에 걸쳐 총 약 30GB의 메모리를 할당하고 있음을 확인할 수 있습니다.
기타 분석 탭
이 프로필에는 유용하게 쓸 수 있는 다른 탭도 몇 가지 포함되어 있습니다.
- System Configuration
- 캡처를 기록한 시스템에 대한 일반 정보입니다.
- rustc 빌드 프로세스
- rustc.exe, cargo.exe, link.exe 등 관련 프로세스를 나열한 평면 목록입니다.
- 각 프로세스에는 해당 명령줄 인자가 나열됩니다.
- 특정 rustc 프로세스가 무슨 작업을 하고 있었는지 파악하는 데 유용합니다.
- rustc 빌드 프로세스 트리
- 프로세스가 시작되고 종료된 시점을 보여 주는 타임라인입니다.
- rustc CPU Analysis
- rustc의 핫스팟을 보여 주도록 미리 구성된 차트가 들어 있습니다.
- 이 차트들은 rustc가 어디에서 시간을 소비하는지 분석하는 데 도움이 되도록 설계되었습니다.
- rustc 메모리 분석
- rustc가 메모리를 할당하는 위치를 보여 주도록 미리 구성된 차트가 들어 있습니다.
rustc-perf로 프로파일링하기
Rust 벤치마크 스위트는 Rust 컴파일러를 프로파일링하고 벤치마킹하는 포괄적인 방법을 제공합니다. 스위트 사용 방법에 대한 안내는 해당 매뉴얼에서 확인할 수 있습니다.
하지만 스위트를 수동으로 사용하는 것은 다소 번거로울 수 있습니다. rustc 기여자가 이를 더 쉽게 할 수 있도록, 컴파일러 빌드 시스템(bootstrap)은 벤치마킹 스위트와의 내장 통합 기능도 제공합니다. 이 기능은 스위트를 대신 다운로드하고 빌드하며, 로컬 컴파일러 툴체인을 빌드하고, 간소화된 명령줄 인터페이스로 이를 프로파일링할 수 있게 해 줍니다.
이 통합 기능을 사용하려면 ./x perf <command> [options] 명령을 사용하면 됩니다.
이 명령에는 --stage 1이나 --stage 2 같은 일반적인 bootstrap 플래그를 사용할 수 있으며, 이를 통해 예를 들어 생성되는 sysroot의 스테이지를 변경할 수 있습니다. 프로파일링을 더 잘 지원하도록 bootstrap.toml을 설정하는 것도 유용할 수 있습니다. 예를 들어 rust.debuginfo-level = 1을 설정하면 빌드된 컴파일러에 소스 줄 정보가 추가됩니다.
x perf는 현재 다음 명령을 지원합니다:
benchmark <id>: 컴파일러를 벤치마크하고 그 결과를 전달된id아래에 저장합니다.compare <baseline> <modified>: 전달된 두id에 해당하는 두 컴파일러의 벤치마크 결과를 비교합니다.eprintln: 컴파일러를 실행하기만 하고stderr출력을 캡처합니다. 컴파일러는 보통stderr에 아무것도 출력하지 않으므로, 출력을 얻으려면eprintln!호출을 몇 개 추가해야 할 수도 있습니다.samply: samply 샘플링 프로파일러를 사용하여 컴파일러를 프로파일링합니다.cachegrind: Cachegrind를 사용하여 컴파일러 실행에 대한 상세한 시뮬레이션 트레이스를 생성합니다.
프로파일러에 대한 더 자세한 설명은
rustc-perf매뉴얼에서 확인할 수 있습니다.
x perf 명령에는 다음 옵션을 사용할 수 있으며, 이 옵션들은 스위트에서 사용할 수 있는 profile_local 및 bench_local 명령의 해당 옵션과 대응합니다:
--include: 프로파일링/벤치마킹할 벤치마크를 선택합니다.--profiles: 프로파일링/벤치마킹할 프로필(Check,Debug,Opt,Doc)을 선택합니다.--scenarios: 프로파일링/벤치마킹할 시나리오(Full,IncrFull,IncrPatched,IncrUnchanged)를 선택합니다.
외부 크레이트에 대한 프로파일링 diff 예제
외부 크레이트에 대해 컴파일러의 두 커밋 간 로컬 diff를 생성하는 것이 유용할 수 있습니다. 먼저 rustc-perf 저장소에서 다음과 같이 Rust 컴파일러 벤치마크를 실행하는 collector를 빌드하십시오.
cargo build --release -p collector
그런 다음 collector 바이너리를 지정하여 cargo로 collector를 실행할 수 있습니다. 다음 인자를 받습니다.
<PROFILE>: 성능을 어떻게 측정할지 정하는 프로파일러 선택입니다. 이 예제에서는 Cachegrind를 사용하겠습니다.<RUSTC>: 벤치마크할 Rust 컴파일러 리비전으로,rust-lang/rust의 커밋 SHA로 지정합니다. 선택적 인자를 사용하면 위에서 설명한 프로필과 시나리오를 실행할 수 있습니다. 필수 인자와 선택 인자에 관한 자세한 내용은 rustc-perf README의 프로파일러 문서에서 확인할 수 있습니다.
그런 다음 rust-lang/rust 저장소의 두 커밋 <SHA1>과 <SHA2>에 대해 크레이트 serve_derive-1.0.136의 프로필 diff를 생성하려면 rustc-perf 저장소에서 다음을 실행하십시오:
cargo run --release --bin collector profile_local cachegrind +<SHA1> --rustc2 +<SHA2> --exact-match serde_derive-1.0.136 --profiles Check --scenarios IncrUnchanged
crates.io 의존성
Rust 컴파일러는 crates.io에서 가져온 일부 의존성을 사용한 빌드를 지원합니다.
Rust Forge에는 새 의존성 검토에 관한 공식 정책이 있습니다.
허용된 의존성
tidy 도구에는 허용된 크레이트 목록이 있습니다. 컴파일러에 아직 포함되지 않은 의존성을 추가하려면 해당 의존성을 이 목록에 추가해야 합니다.
기여 절차
버그 보고
버그는 달갑지 않지만, 소프트웨어에서 피할 수 없는 현실입니다. 저희가 모르는 문제는 고칠 수 없으므로, 부담 없이 적극적으로 보고해 주십시오. 어떤 것이 버그인지 확실하지 않더라도 주저하지 말고 이슈를 열어 주십시오.
버그를 공개적으로 보고하는 것이 Rust 사용자에게 보안 위험이 된다고 판단되면, 보안 취약점 보고 지침을 따라 주십시오.
nightly 채널을 사용하고 있다면, 버그를 제출하기 전에 최신 툴체인에서도 해당 버그가 발생하는지 확인해 주십시오. 이미 수정되었을 수도 있습니다.
다른 사람이 같은 오류를 이미 보고했을 수도 있으므로, 여유가 있다면 버그를 보고하기 전에 기존 이슈를 검색해 주십시오. 검색이 항상 효과가 있는 것은 아니고 무엇을 검색해야 할지 알기 어려울 때도 있으므로, 이는 필수가 아닌 추가 노력으로 여기십시오. 실수로 중복 보고를 제출하더라도 괜찮습니다.
마찬가지로, 같은 버그를 겪은 다른 사람들이 여러분의 이슈를 찾을 수 있도록, 해당 버그에만 해당할 수 있는 정보를 담은 설명적인 제목으로 이슈를 제출하는 것을 고려해 주십시오. 사용한 언어 기능이나 컴파일러 기능, 버그를 유발하는 조건, 또는 오류 메시지가 있다면 그 일부를 제목에 넣을 수 있습니다. 예를 들면 다음과 같습니다: 반환 위치의 impl 트레이트에 대한 라이프타임 추론에서 “impossible case reached” 발생.
이슈를 여는 방법은 간단합니다. 이 링크를 따라가서 제공된 템플릿 중 알맞은 것의 항목을 채우면 됩니다.
기타 절차
그 밖의 기여 절차는 가이드에서 해당 내용을 다루는 부분에 문서화되어 있습니다.
- 일상적인 변경이나 여러 영역에 걸친 대규모 풀 리퀘스트에 대해서는 일반적인 코드 변경, PR 생명주기, 풀 리퀘스트 안내를 참고하십시오.
- 새 기능, 주요 변경, 호환성을 깨는 변경에 대해서는 새 언어 기능 구현하기와 호환성을 깨는 변경 절차를 참고하십시오.
- 성능에 민감한 변경에 대해서는 성능 테스트를 참고하십시오.
- 컴파일러 문서화에 대해서는 문서 기여하기를 참고하십시오.
- 이슈 분류에 대해서는 이슈 분류를 참고하십시오.
- LLM 사용 안내는 LLM 실행하기를 참고하십시오.
- 이 가이드 자체를 변경하려면 rustc-dev-guide 문서 작성하기를 참고하십시오.
- 외부 의존성에 대해서는 외부 저장소 사용하기를 참고하십시오.
- 추가 자료는 이 가이드에 대하여를 참고하십시오.
PR 생명 주기
리뷰 전
PR 열기
이제 풀 리퀘스트(PR)를 제출할 준비가 되셨습니까? 좋습니다! 알아 두어야 할 몇 가지 사항은 다음과 같습니다.
다른 브랜치를 대상으로 해야 한다고 확실히 알고 있는 경우가 아니라면, 모든 풀 리퀘스트는 main 브랜치를 대상으로 제출해야 합니다.
PR을 제출하기 전에 몇 가지 스타일 검사를 실행하십시오:
./x test tidy --bless
모든 풀 리퀘스트 전에(그리고 풀 리퀘스트의 모든 새 커밋 전에) 이 검사를 수행할 것을 권장합니다. 이 검사를 잊지 않도록 매 푸시 전에 실행되는 git 훅을 추가할 수 있습니다. CI도 tidy를 실행하며, tidy가 실패하면 CI도 실패합니다.
PR 설명
GitHub에서는 키워드를 사용하여 이슈를 닫을 수 있습니다. 이슈 트래커를 깔끔하게 유지하려면 이 기능을 사용해야 합니다. 다만 일반적으로 “closes #123” 문구는 커밋 메시지보다 PR 설명에 넣는 것이 좋습니다. 특히 리베이스 중에 커밋에서 이슈 번호를 언급하면 해당 이슈에 “스팸“이 쌓일 수 있습니다.
하지만 PR이 stable-to-beta 또는 stable-to-stable 회귀를 수정하며 베타 및/또는 stable 백포트가 승인된 경우(즉, beta-accepted 및/또는 stable-accepted로 표시된 경우), 수정 사항이 main에 반영될 때 해당 이슈가 자동으로 닫히지 않도록 그러한 키워드를 사용하지 마십시오. PR 설명을 수정하되 이슈는 어딘가에 계속 언급해 두십시오. 예를 들어 Fixes (after beta backport) #NNN.처럼 쓸 수 있습니다.
CI
풀 리퀘스트는 사람이 리뷰할 뿐만 아니라 지속적 통합(CI) 덕분에 자동으로 테스트도 됩니다. 기본적으로 풀 리퀘스트를 열거나 업데이트할 때마다 CI가 컴파일러를 빌드하고 컴파일러 테스트 스위트로 테스트하며, 풀 리퀘스트가 Rust의 스타일 가이드라인을 준수하는지 확인하는 등의 다른 테스트도 수행합니다.
지속적 통합 테스트를 실행하면 PR 작성자는 첫 번째 리뷰 주기를 거치지 않고도 실수를 일찍 발견할 수 있고, 리뷰어는 특정 풀 리퀘스트의 상태를 계속 파악할 수 있습니다.
Rust에는 CI 용량이 충분하므로, 변경 사항을 푸시할 때마다 컴퓨팅 자원을 낭비하는 것은 아닌지 걱정할 필요가 전혀 없습니다. 생산성에 도움이 된다면 CI를 사용하여 변경 사항을 테스트하는 것도 전혀 문제없습니다(오히려 권장합니다!). 특히 전체 ./x test 스위트는 실행하는 데 매우 오랜 시간이 걸리므로 로컬에서 실행하는 것을 권장하지 않습니다. Rust의 CI를 사용하여 변경 사항을 테스트하는 방법은 CI로 테스트하기 장을 참고하십시오.
PR 리뷰
r?
PR에는 리뷰어가 자동으로 배정됩니다. r? @username을 사용하여 리뷰어를 바꿀 수 있습니다. 자세한 내용은 PR 할당을 참고하십시오.
리베이스
Rust는 병합 커밋 금지 정책을 따릅니다. 따라서 병합 충돌이 발생하면 병합 대신 항상 리베이스를 해야 합니다. 예를 들어 main 브랜치의 최신 변경 사항을 기능 브랜치로 가져올 때는 항상 리베이스를 사용하십시오. PR에 병합 커밋이 포함되어 있으면 has-merge-commits로 표시됩니다. 대화형 리베이스 등으로 병합 커밋을 제거한 후에는 해당 레이블을 다시 제거해야 합니다:
@rustbot label -has-merge-commits
자세한 내용은 이 장을 참조하십시오.
병합 충돌이 발생하거나 리뷰어가 변경을 요청하면 PR은 S-waiting-on-author로 표시됩니다. 이를 해결한 후에는 @rustbot을 사용하여 PR을 S-waiting-on-review로 표시해야 합니다:
@rustbot ready
브랜치를 최신 상태로 유지하기
rust-lang/rust의 CI는 브랜치의 기반 커밋이 아니라 현재 main에 패치를 직접 적용합니다. 따라서 브랜치가 오래되었다면 명시적인 병합 충돌이 없더라도 예기치 않은 실패가 발생할 수 있습니다.
브랜치는 필요할 때만 업데이트하십시오. 병합 충돌이 있을 때, 업스트림 CI가 망가져 통과한 PR을 막고 있을 때, 또는 메인테이너가 요청할 때가 이에 해당합니다. 리뷰 중이며 이미 통과한 PR은 꼭 필요한 경우가 아니면 업데이트하지 마십시오. 리뷰 중에는 피드백을 반영하는 커밋을 점진적으로 추가하십시오. 스쿼시나 리베이스는 마지막에 하거나 리뷰어가 요청할 때만 하는 것이 좋습니다.
업데이트할 때는 git push --force-with-lease를 사용하고, 무엇이 바뀌었는지 설명하는 간단한 댓글을 남기십시오. 일부 저장소는 리베이스 대신 upstream/main에서 병합하는 방식을 선호하므로 해당 프로젝트의 관례를 따르십시오. 자세한 방법은 최신 상태 유지하기를 참조하십시오.
리베이스한 후에는 CI가 실행되기 전에 문제를 발견할 수 있도록 관련 테스트를 로컬에서 실행하는 것이 좋습니다.
리뷰 기다리기
참고
풀 리퀘스트 리뷰어는 대개 업무량이 한계에 이른 상태이며, 그중 상당수는 자원봉사로 기여하고 있습니다. 리뷰 지연을 최소화하려면 풀 리퀘스트 작성자와 담당 리뷰어는 적절한 시점에 다음 명령을 사용하여 리뷰 레이블(
S-waiting-on-review및S-waiting-on-author)이 항상 최신 상태로 유지되도록 해야 합니다:
@rustbot author: 리뷰가 끝났으므로 PR 작성자가 댓글을 확인하고 그에 따라 조치해야 합니다.
@rustbot ready: 작성자가 리뷰받을 준비가 되었으며, 이 PR이 리뷰어의 대기열에 다시 추가됩니다.
리뷰어도 사람이며, 대부분 여가 시간에 rustc 작업을 한다는 점을 유념하십시오. 따라서 리뷰어가 응답하고 PR을 리뷰하는 데 시간이 걸릴 수 있습니다. 또한 리뷰어가 자신에게 할당된 PR 중 일부를 놓칠 수도 있습니다.
PR 진행을 돕기 위해 Triage WG는 리뷰를 기다리면서 최소 2주 동안 논의가 없었던 모든 PR을 정기적으로 검토합니다. 2주 안에 리뷰를 받지 못하면 Zulip(#t-release/triage)에서 Triage WG에 편하게 문의하십시오. Triage WG는 언제 핑을 보내야 하는지, 누가 휴가 중일 수 있는지 등을 알고 있습니다.
리뷰어는 GitHub 코드 리뷰 인터페이스를 사용하여 몇 가지 변경을 요청할 수 있습니다. 또한 일부 PR에 대해서는 특별한 절차를 요청할 수도 있습니다. 이러한 절차의 몇 가지 예시는 Crater 및 호환성을 깨는 변경 장을 참고하십시오.
이해되지 않거나 동의하지 않는 부분이 있다면 자유롭게 질문하거나 논의하십시오. 다만 Rust 팀의 누군가가 승인하지 않는 한 PR은 병합되지 않는다는 점을 인지하십시오. 리뷰어가 r=me after fixing ...와 같은 코멘트를 남겼다면, 이는 리뷰어가 PR을 승인했다는 뜻이며, 사소한 문제를 수정한 후 @bors r=reviewer-github-id(예: @bors r=eddyb) 코멘트를 달아 병합할 수 있습니다. r=someone에는 권한이 필요하며, r=someone을 코멘트로 달았을 때 bors가 “🔑 Insufficient privileges…“와 같은 메시지를 남길 수 있다는 점에 유의하십시오. 그런 경우에는 리뷰어에게 PR을 다시 확인해 달라고 요청해야 합니다.
리뷰 과정에서 일부 PR에는 다음과 같은 몇 가지 일이 일어날 수 있습니다
- 변경 사항이 충분히 큰 경우, 리뷰어는 해당 PR에 대해 FCP를 요청할 수 있습니다. 이를 통해 관련 팀의 모든 구성원이 변경 사항을 검토할 기회를 갖게 됩니다.
- 변경 사항이 기존 코드를 깨뜨릴 수 있는 경우, 리뷰어는 crater 실행을 요청할 수 있습니다. 이는 여러분의 변경 사항을 반영하여 컴파일러를 빌드한 후, 수정된 컴파일러로 crates.io의 모든 크레이트를 컴파일해 보는 작업입니다. 이는 여러분이 생태계의 상당 부분에 영향을 미치는 컴파일러 동작 변경을 도입했는지 확인하는 훌륭한 스모크 테스트입니다.
- PR의 diff가 크거나 리뷰어가 바쁜 경우, 먼저 병합된 다른 PR과 여러분의 PR 사이에 병합 충돌이 발생할 수 있습니다. 이러한 병합 충돌은 일반적인 git 절차에 따라 해결하십시오.
r+
누군가 여러분의 풀 리퀘스트를 리뷰하고 나면, 풀 리퀘스트에 r+라는 표시를 남깁니다. 다음과 같은 형태입니다:
@bors r+
이는 우리의 사랑스러운 통합 봇인 @bors에게 여러분의 풀 리퀘스트가 승인되었음을 알립니다. 그러면 PR은 병합 대기열에 들어가며, 그곳에서 @bors가 우리가 지원하는 모든 플랫폼에서 모든 테스트를 실행합니다.
변경의 규모에 따라 약간 다른 형태의 r+를 보게 될 수도 있습니다:
@bors r+ rollup
추가된 rollup은 @bors에게 이 변경 사항이 항상 “롤업“되어야 함을 알립니다. 롤업된 변경 사항은 과정을 빠르게 진행하기 위해 다른 PR과 함께 테스트되고 병합됩니다. 일반적으로 서로 충돌하지 않을 것으로 예상되는 작은 변경 사항만 “항상 롤업“으로 표시됩니다.
인내심을 가지십시오. 이 과정은 시간이 걸릴 수 있으며 대기열이 길어지는 경우도 있습니다. 또한 PR은 절대 수동으로 병합되지 않는다는 점에 유의하십시오.
모든 것이 잘 진행되면 @bors가 여러분의 코드를 main에 병합하고 풀 리퀘스트를 닫습니다. 여러분의 코드는 다음 nightly 컴파일러에 포함됩니다 :)
병합 이후
백포트
이후 조치로, 제목이 [beta] 또는 [stable]로 시작하면서 해당 PR을 백포트하는 PR이 올라오는지 주의 깊게 지켜보십시오. 그 PR이 병합되면 관련 이슈를 닫을 수 있습니다. 이슈를 닫을 때 남기는 코멘트에는 관련된 모든 PR을 언급해야 합니다. 이슈를 닫을 권한이 없다면 원래 PR에 코멘트를 남겨 리뷰어에게 이슈를 대신 닫아 달라고 요청하십시오.
PR 되돌리기
Forge의 “Reverts”를 참고하십시오.
PR이 너무 커서 되돌리기 어렵다면 #128271에서처럼 문제가 되는 코드의 트리거만 비활성화해도 괜찮습니다. MIR 최적화의 경우 #132356에서처럼 -Zunsound-mir-opt 옵션으로 해당 mir-opt를 게이트할 수도 있습니다.
rustc-dev-guide 문서 작성하기
rustc-dev-guide에 대한 기여는 언제나 환영하며, rust-lang/rustc-dev-guide 저장소에서 직접 기여하실 수 있습니다. 해당 저장소의 이슈 트래커도 해야 할 일을 찾기에 좋은 곳입니다. 초보자부터 숙련된 컴파일러 개발자까지 모두를 위한 이슈가 있습니다!
몇 가지 유의할 사항은 다음과 같습니다.
-
컴파일러 코드의 특정 부분에 대해 작성할 때는 rustc API 문서의 관련 부분으로 링크하기를 권장합니다.
-
장과 절의 제목에는 문장형 대소문자 표기(sentence case)를 사용하십시오.
-
파일 이름에서 단어를 구분할 때는 대시(
-)를 사용하십시오. -
가이드 내부 링크는
.html링크가 아닌.md상대 링크를 사용해야 합니다. CI가 이를 강제합니다. -
지나치게 긴 줄은 피하고, 의미 단위 줄바꿈(각 문장 뒤에서 줄을 바꾸는 방식)을 사용하십시오. 이렇게 하면 관련 없는 다른 문장의 줄이 다시 배치되지 않으므로 diff를 검토하기가 더 쉬워집니다. 줄 길이에 엄격한 제한은 없으므로, 문장이나 문장의 일부가 같은 줄에서 적절한 끝까지 이어지도록 두십시오.
ci/sembr에 있는 도구를 사용하면 이 작업에 도움이 됩니다. 이 도구의 도움말 출력은 다음 명령으로 확인할 수 있습니다.
cargo run --manifest-path ci/sembr/Cargo.toml -- --help -
가이드에 글을 기여할 때는 독자가 해당 정보를 얼마나 신뢰할 수 있는지 알 수 있도록 시기나 이유, 또는 둘 다를 밝혀 정보의 맥락을 제시해 주십시오. 적절한 양의 맥락을 제공하는 것을 목표로 하고, 다음 내용을 포함하는 것을 고려하십시오.
-
변경은 프로젝트 전반에서 늘 일어나는 일이므로, 글이 오래되었을 수 있는 이유를 “변경” 이외의 것으로 제시하십시오.
-
해당 내용이 추가된 날짜를 밝히십시오. 예를 들어 “현재, …“나 “지금으로서는, …“이라고 쓰는 대신 다음 형식 중 하나로 날짜를 추가하는 것을 고려하십시오.
- Jan 2021
- January 2021
- jan 2021
- january 2021
6개월이 지난 항목을 보여 주는 월간 보고서를 생성하는 CI 액션(
.github/workflows/date-check.yml에 있음)이 있습니다(예시).이 액션이 날짜를 인식할 수 있도록 날짜를 명시하기 전에 특별한 주석을 추가하십시오.
<!-- date-check --> Jul 2026예시:
As of <!-- date-check --> Jul 2026, the foo did the bar.날짜가 렌더링된 출력에 보이지 않아야 하는 경우에는 대신 다음을 사용하십시오.
<!-- date-check: Jul 2026 --> -
변경 과정에 대한 추가 설명이나 정보가 오래되지 않았음을 확인하는 방법을 제공할 수 있는 관련 WG, 추적 이슈,
rustcrustdoc 페이지 등에 대한 링크를 포함하십시오.
-
⚠️ 참고: rustc-dev-guide 변경 사항을 기여할 곳
rustc-dev-guide 변경 사항을 어디에 기여해야 하는지와 그렇게 할 때의 이점에 대한 자세한 내용은 rustc-dev-guide 팀 문서를 참고하십시오.
컴파일러 팀 소개
참고: 팀에 관한 자세한 내용은 Forge에 많이 나와 있으므로, 아래 내용은 대부분 더 이상 유효하지 않습니다.
rustc는 Rust 컴파일러 팀이 유지 관리합니다. 이 팀에 속한 사람들은 함께 회귀를 추적하고 새로운 기능을 구현합니다. Rust 컴파일러 팀의 구성원은 rustc와 그 설계에 크게 기여한 사람들입니다.
논의
현재 컴파일러 팀은 Zulip에서 대화합니다.
- 팀 대화는 Zulip 인스턴스의
t-compiler스트림에서 이루어집니다. - 이 밖에도 관련 Zulip 채널이 여럿 있습니다. 예를 들어
t-compiler/help에서는 rustc 개발에 관해 도움을 요청할 수 있고,t-compiler/meetings에서는 팀이 매주 트리아지 회의와 운영 회의를 엽니다.
리뷰어
컴파일러의 특정 부분에 관한 질문에 누가 답할 수 있는지 알고 싶거나, 누가 어떤 일을 맡고 있는지 궁금하다면 triagebot.toml의 assign 섹션을 확인하십시오. 이 섹션에는 컴파일러의 여러 부분과 각 부분의 리뷰어 목록이 나와 있습니다.
Rust 컴파일러 회의
컴파일러 팀은 매주 회의를 열어 트리아지를 진행하고, 새로운 버그와 회귀를 전반적으로 놓치지 않도록 살피며, 중요한 사안을 두루 논의합니다. 회의는 Zulip에서 열립니다. 대략 다음과 같이 진행됩니다.
- 공지, MCP/FCP, WG 점검: 모두가 알아야 할 중요한 사항을 팀원들에게 공지합니다. 또한 MCP와 FCP의 상태를 공유하고, 이 기회를 활용해 몇몇 WG로부터 작업 현황을 보고받습니다.
- 베타 및 stable 지명 확인: 이는 각각 베타와 stable로 백포트할 항목에 대한 지명입니다. 그다음에는 기존에 잘 작동하던 실사용 코드를 컴파일러가 깨뜨린 새로운 사례를 찾습니다. 회귀는 반드시 고쳐야 하는 중요한 이슈이므로 P-critical이나 P-high로 태그될 가능성이 높습니다. 주요 예외는 버그 수정입니다(그런 경우에도 먼저 경고를 내는 것을 목표로 하는 경우가 많습니다).
- P-critical 및 P-high 버그 검토: P-critical과 P-high 버그는 진행 상황을 적극적으로 추적해야 할 만큼 중요한 버그입니다. P-critical과 P-high 버그에는 이상적으로는 항상 담당자가 지정되어 있어야 합니다.
S-waiting-on-t-compiler및I-compiler-nominated이슈 확인: 팀의 피드백이 필요한 이슈입니다.- 성능 트리아지 보고서 검토: 성능을 떨어뜨린 PR을 확인하고, 해당 성능 회귀를 되돌릴 가치가 있는지 아니면 이후 PR에서 해결할 수 있는지 판단합니다.
회의는 현재 매주 목요일 보스턴 시간 오전 10시에 열립니다(보통 UTC-4이지만, 서머타임 때문에 가끔 복잡해집니다).
팀 구성원 자격
Rust 팀 구성원 자격은 보통 어떤 사람이 일정 기간 컴파일러에 크게 기여해 왔을 때 제안됩니다. 구성원 자격은 인정인 동시에 의무이기도 합니다. 컴파일러 팀 구성원은 일반적으로 리뷰를 비롯한 여러 작업뿐 아니라 유지 관리도 도울 것으로 기대됩니다.
컴파일러 팀 구성원이 되는 데 관심이 있다면, 먼저 버그를 고치기 시작하거나 워킹 그룹에 참여하십시오. 버그를 찾는 좋은 방법 하나는 E-easy 태그가 붙은 열린 이슈나 E-mentor 이슈를 찾아보는 것입니다.
활동이 없어 닫힌 PR 더미를 뒤져 보는 것도 좋습니다. 그중에는 여전히 쓸모 있는 작업이 들어 있어서, 원래 작성자가 시간이 없어 하지 못한 마무리 작업만 하면 되는 PR도 있을 수 있습니다. 관련 이슈가 있다면 함께 참고하십시오.
r+ 권한
rustc에 개별 PR을 여러 차례 보내고 나면 저희가 r+ 권한을 드리는 경우가 많습니다. 이는 “bors”(어떤 PR을 rustc에 반영할지 관리하는 로봇)에게 PR 병합을 지시할 권한이 생긴다는 뜻입니다(bors와 소통하는 방법은 이 안내를 참고하십시오).
리뷰어를 위한 지침은 다음과 같습니다.
- PR이 누구에게 할당되었든 관계없이 어떤 PR이든 언제든지 리뷰하셔도 좋습니다. 다만 다음 조건을 만족하지 않는다면 PR에 r+를 하지 마십시오.
- 코드의 해당 부분을 확실히 알고 있는 경우입니다.
- 다른 누구도 먼저 리뷰하기를 원하지 않는다고 확신하는 경우입니다.
- 예를 들어, PR이 코드의 특히 민감한 부분을 건드린다는 등의 이유로 반영되기 전에 직접 리뷰하고 싶다는 의사를 밝히는 사람이 있을 수 있습니다.
- 리뷰할 때는 항상 예의를 지키십시오. 여러분은 Rust 프로젝트를 대표하는 사람이므로 행동 강령에 관해서는 기대 이상의 모범을 보여야 합니다.
리뷰어 순번
r+ 권한을 받으면 리뷰어 순번에도 추가될 수 있습니다. triagebot은 새로 들어오는 PR을 리뷰어에게 자동으로 할당하는 봇입니다. 순번에 추가되면 PR을 리뷰할 사람으로 무작위로 선정됩니다. 리뷰하기 부담스러운 PR이 할당되었다면 r? @so-and-so와 같은 댓글을 남겨 다른 사람에게 할당할 수도 있습니다. 누구에게 요청해야 할지 모르겠다면 r? @nikomatsakis for reassignment라고만 적으십시오. 그러면 @nikomatsakis가 적임자를 골라 드립니다.
리뷰어 순번에 참여해 주시면 모두의 리뷰 부담이 줄어들므로 큰 도움이 됩니다! 하지만 PR에 제때 피드백을 드릴 시간이 없다면 명단에 오르지 않는 편이 나을 수도 있습니다.
Git 사용하기
Rust 프로젝트는 소스 코드를 관리하기 위해 Git을 사용합니다. 기여하려면 변경 사항이 컴파일러에 반영될 수 있도록 Git의 기능에 어느 정도 익숙해야 합니다.
이 페이지의 목표는 새 기여자가 흔히 겪는 질문과 문제 몇 가지를 다루는 것입니다. 여기서 Git의 기초도 일부 다루지만, 그래도 진행이 조금 빠르다고 느껴진다면 Atlassian의 이 튜토리얼의 Beginner 및 Getting started 섹션과 같은 Git 입문 자료를 먼저 읽어 보는 것이 좋습니다. GitHub에서도 초보자를 위한 문서와 가이드를 제공하며, 더 깊이 있는 Git 공식 책을 참고할 수도 있습니다.
이 가이드는 아직 완전하지 않습니다. 이 페이지로 해결되지 않는 git 문제를 겪는다면, 해결 방법을 문서화할 수 있도록 이슈를 열어 주십시오.
사전 준비 사항
여러분이 Git을 설치하고, rust-lang/rust를 포크한 뒤, 포크한 저장소를 PC에 클론했다고 가정합니다. 여기서는 Git을 다룰 때 명령줄 인터페이스를 사용하지만, 대체로 같은 작업을 할 수 있는 GUI와 IDE 통합 도구도 많이 있습니다.
포크를 클론했다면 로컬 저장소에서 origin으로 포크를 참조할 수 있습니다. 공식 rust-lang/rust 저장소에 대한 원격 저장소도 다음과 같이 설정해 두면 도움이 될 수 있습니다.
git remote add upstream https://github.com/rust-lang/rust.git
위는 HTTPS를 사용하는 경우이고,
git remote add upstream git@github.com:rust-lang/rust.git
위는 SSH를 사용하는 경우입니다.
참고: 이 페이지는 rust-lang/rust의 워크플로를 다루지만, Rust 프로젝트의 다른 저장소에 기여할 때도 유용할 것입니다.
표준 절차
다음은 대부분의 사소한 변경 사항과 PR에 사용하게 될 일반적인 절차입니다.
main위에서 변경 작업을 하고 있는지 확인하십시오:git checkout main.- Rust 저장소에서 최신 변경 사항을 가져오십시오:
git pull upstream main --ff-only. (이에 대한 자세한 내용은 병합 금지 정책을 참고하십시오.) - 변경 사항을 위한 새 브랜치를 만드십시오:
git checkout -b issue-12345-fix. - 저장소를 변경하고 테스트하십시오.
git add src/changed/file.rs src/another/change.rs로 변경 사항을 스테이징한 다음git commit으로 커밋하십시오. 물론 중간 커밋을 만드는 것도 좋은 방법일 수 있습니다.git add .는 서브모듈 업데이트처럼 커밋하면 안 되는 변경 사항을 의도치 않게 커밋하기 너무 쉬우므로 사용하지 마십시오.git status를 사용하면 스테이징을 잊은 파일이 있는지 확인할 수 있습니다.- 변경 사항을 포크에 푸시하십시오:
git push --set-upstream origin issue-12345-fix(커밋을 추가한 후에는git push를, 리베이스하거나 pull 후 리베이스한 후에는git push --force-with-lease를 사용할 수 있습니다). - 포크에서
rust-lang/rust의main브랜치로 PR을 여십시오.
리베이스가 필요한데 충돌이 발생한다면 리베이스를 참고하십시오. 오래 걸리는 기능이나 이슈를 작업하면서 업스트림을 추적하고 싶다면 최신 상태 유지하기를 참고하십시오.
리뷰어가 변경을 요청하면, 그 변경을 위한 절차도 몇몇 단계를 건너뛸 뿐 거의 같습니다.
- 코드의 최신 버전에서 변경 작업을 하고 있는지 확인하십시오:
git checkout issue-12345-fix. - 이전과 마찬가지로 추가 변경 사항을 만들고, 스테이징하고, 커밋하십시오.
- 변경 사항을 포크에 푸시하십시오:
git push.
git 문제 해결
rust-lang/rust가 오래되었더라도 처음부터 다시 클론할 필요는 없습니다! 돌이킬 수 없을 정도로 망가뜨렸다고 생각하더라도, 저장소 전체를 다시 내려받지 않고 git 상태를 고치는 방법이 있습니다. 다음은 자주 겪을 수 있는 몇 가지 문제입니다:
실수로 병합 커밋을 만들었습니다.
Git에서 브랜치를 최신 변경 사항으로 갱신하는 방법은 병합과 리베이스, 두 가지가 있습니다. Rust는 리베이스를 사용합니다. 병합 커밋을 만들었더라도 고치기는 그리 어렵지 않습니다: git rebase -i upstream/main.
리베이스에 대한 자세한 내용은 리베이스를 참고하십시오.
GitHub에서 포크를 삭제했습니다!
git의 관점에서는 문제가 되지 않습니다. git remote -v를 실행하면 다음과 같이 출력됩니다:
$ git remote -v
origin git@github.com:jyn514/rust.git (fetch)
origin git@github.com:jyn514/rust.git (push)
upstream https://github.com/rust-lang/rust (fetch)
upstream https://github.com/rust-lang/rust (fetch)
포크의 이름을 바꿨다면 다음과 같이 URL을 변경할 수 있습니다:
git remote set-url origin <URL>
여기서 <URL>은 새 포크의 URL입니다.
실수로 서브모듈을 변경했습니다
보통은 rustbot이 GitHub에 cargo가 수정되었다는 댓글을 남길 때 이를 알아차립니다:
웹 UI에서 충돌을 발견할 수도 있습니다:
가장 흔한 원인은 변경 후 리베이스를 하고 나서, 서브모듈을 갱신하는 x를 먼저 실행하지 않은 채 git add .를 실행한 경우입니다. 또는 x fmt 대신 cargo fmt를 실행하여 서브모듈 안의 파일을 수정한 뒤 그 변경 사항을 커밋했을 수도 있습니다.
이를 고치려면 다음 단계를 수행하십시오(cargo가 아닌 다른 서브모듈을 변경했다면 src/tools/cargo를 해당 서브모듈의 경로로 바꾸십시오):
- 실수로 인한 변경 사항이 어느 커밋에 있는지 확인하십시오:
git log --stat -n1 src/tools/cargo - 해당 커밋의 변경 사항을 되돌리십시오:
git checkout <my-commit>~ src/tools/cargo.~는 그대로 입력하되<my-commit>은 1단계의 출력으로 바꾸십시오. - git에 변경 사항을 커밋하도록 지시하십시오:
git commit --fixup <my-commit> - 수정한 모든 서브모듈에 대해 1~3단계를 반복하십시오.
- 여러 커밋에 걸쳐 서브모듈을 수정했다면, 수정한 각 커밋마다 1~3단계를 반복해야 합니다.
git log명령이 본인이 작성하지 않은 커밋을 보여 주면 멈추면 됩니다.
- 여러 커밋에 걸쳐 서브모듈을 수정했다면, 수정한 각 커밋마다 1~3단계를 반복해야 합니다.
- 변경 사항을 기존 커밋에 스쿼시하십시오:
git rebase --autosquash -i upstream/main - 변경 사항을 푸시하십시오.
리베이스하려고 하면 “error: cannot rebase“가 표시됩니다
리베이스할 때 흔히 볼 수 있는 두 가지 오류는 다음과 같습니다:
error: cannot rebase: Your index contains uncommitted changes.
error: Please commit or stash them.
error: cannot rebase: You have unstaged changes.
error: Please commit or stash them.
(둘의 차이점은 https://git-scm.com/book/en/v2/Getting-Started-What-is-Git%3F#_the_three_states를 참조하십시오.)
이는 마지막으로 커밋한 이후에 변경한 내용이 있다는 뜻입니다. 리베이스하려면 변경 사항을 커밋하거나, “stash“라는 임시 커밋을 만들어 리베이스를 마친 뒤에도 변경 사항이 커밋되지 않은 상태로 남아 있게 하십시오. git이 이 “stash“를 자동으로 만들도록 설정할 수도 있으며, 이렇게 하면 거의 모든 경우에 “cannot rebase” 오류(리베이스할 수 없음)를 방지할 수 있습니다:
git config --global rebase.autostash true
stash에 대한 자세한 내용은 https://git-scm.com/book/en/v2/Git-Tools-Stashing-and-Cleaning을 참조하십시오.
’Untracked Files: src/stdarch’가 표시됩니까?
이는 library/ 디렉터리로 옮기는 과정에서 남은 것입니다. 안타깝게도 git rebase는 서브모듈의 이름 변경을 추적하지 않으므로, 디렉터리를 직접 삭제해야 합니다:
rm -r src/stdarch
<<< HEAD가 표시됩니까?
아마도 리베이스나 병합 충돌을 처리하던 중이었을 것입니다. 충돌을 해결하는 방법은 충돌을 참조하십시오. 변경 사항이 필요 없고 저장소의 깨끗한 사본을 되찾기만 하면 된다면 git reset을 사용할 수 있습니다:
# WARNING: this throws out any local changes you've made! Consider resolving the conflicts instead.
git reset --hard main
일부 ref를 푸시하지 못했습니다
git push가 제대로 동작하지 않고 다음과 같은 메시지를 출력합니다:
! [rejected] issue-xxxxx -> issue-xxxxx (non-fast-forward)
error: failed to push some refs to 'https://github.com/username/rust.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
이 메시지가 제시하는 조언은 잘못되었습니다! Rust의 “병합 금지” 정책 때문에 git pull이 만드는 병합 커밋은 리베이스의 목적 자체를 무너뜨릴 뿐 아니라 최종 PR에서도 허용되지 않습니다! 대신 git push --force-with-lease를 사용하십시오.
직접 작성하지 않은 커밋을 Git이 리베이스하려고 합니까?
리베이스 목록에 커밋이 많거나, 병합 커밋이 있거나, 직접 작성하지 않은 다른 사람의 커밋이 보인다면 잘못된 브랜치 위로 리베이스하려는 것일 가능성이 높습니다. 예를 들어 rust-lang/rust를 가리키는 원격 저장소 upstream이 있는데, git rebase upstream/main 대신 git rebase origin/main을 실행했을 수 있습니다. 해결 방법은 리베이스를 중단하고 올바른 브랜치를 사용하는 것입니다:
git rebase --abort
git rebase --interactive upstream/main
잘못된 브랜치 위로 리베이스한 예를 보려면 여기를 클릭하십시오
서브모듈에 관한 간단한 참고 사항
git pull로 로컬 저장소를 업데이트하다 보면, 한 번도 편집하지 않은 파일을 수정했다고 Git이 알려 주는 경우가 가끔 있습니다. 예를 들어 git status를 실행하면 다음과 같은 결과가 나옵니다(new commits라는 언급에 주목하십시오):
On branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: src/llvm-project (new commits)
modified: src/tools/cargo (new commits)
no changes added to commit (use "git add" and/or "git commit -a")
이 변경 사항은 파일의 변경이 아니라 서브모듈의 변경입니다(자세한 내용은 뒤에서 다룹니다). 이를 없애려면 다음과 같이 하십시오:
git submodule update
일부 서브모듈은 실제로는 필요하지 않습니다. 예를 들어 download-ci-llvm을 사용한다면 src/llvm-project를 체크아웃할 필요가 없습니다. 해당 서브모듈의 히스토리를 계속 가져오지 않으려면 git submodule deinit -f src/llvm-project를 사용할 수 있습니다. 이렇게 하면 이 서브모듈이 다시 수정됨으로 표시되는 일도 막을 수 있습니다.
리베이스와 충돌
로컬에서 코드를 편집하면, 기능 브랜치를 만들 당시에 존재하던 버전의 rust-lang/rust에 변경을 가하는 것입니다. 따라서 PR을 제출할 때, 그 이후 rust-lang/rust에 이루어진 변경 사항 중 일부가 여러분이 만든 변경 사항과 충돌할 수 있습니다. 이런 경우에는 변경 사항이 병합되기 전에 충돌을 해결해야 합니다. 그렇게 하려면 작업한 내용을 rust-lang/rust 위로 리베이스해야 합니다.
리베이스
기능 브랜치를 rust-lang/rust의 main 브랜치 최신 버전 위로 리베이스하려면, 브랜치를 체크아웃한 다음 이 명령을 실행하십시오.
git pull --rebase https://github.com/rust-lang/rust.git main
다음과 같은 오류가 발생한다면:
error: cannot pull with rebase: Your index contains uncommitted changes. error: please commit or stash them.작업 트리에 커밋되지 않은 작업이 있다는 뜻입니다. 이 경우 리베이스하기 전에
git stash를 실행하고, 리베이스를 마치고 모든 충돌을 해결한 뒤에git stash pop을 실행하십시오.
브랜치를 main 위로 리베이스하면, 브랜치의 모든 변경 사항이 main의 가장 최신 버전에 다시 적용됩니다. 다시 말해, Git은 여러분이 main의 이전 버전에 가한 변경 사항을 마치 main의 새 버전에 가한 것처럼 처리하려고 합니다. 이 과정에서 적어도 한 번은 “리베이스 충돌“을 겪게 될 것이라고 예상해야 합니다. 이는 여러분의 변경 사항이 이미 이루어진 다른 변경 사항과 충돌하여 Git이 변경 사항을 다시 적용하는 데 실패할 때 발생합니다. 이런 일이 일어났는지는 출력에서 다음과 같은 줄이 보이는지로 알 수 있습니다.
CONFLICT (content): Merge conflict in file.rs
이 파일들을 열면 다음과 같은 형태의 부분이 보입니다.
<<<<<<< HEAD
Original code
=======
Your code
>>>>>>> 8fbf656... Commit fixes 12345
이는 파일에서 Git이 어떻게 리베이스해야 할지 알아내지 못한 줄들을 나타냅니다. <<<<<<< HEAD와 ======= 사이의 부분에는 main의 코드가 있고, 다른 쪽에는 여러분 버전의 코드가 있습니다. 충돌을 어떻게 처리할지 결정해야 합니다. 여러분의 변경 사항을 유지하거나, main의 변경 사항을 유지하거나, 둘을 합칠 수 있습니다.
일반적으로 충돌 해결은 두 단계로 이루어집니다. 먼저, 해당 충돌을 수정하십시오. 원하는 변경 사항이 반영되도록 파일을 편집하고, 그 과정에서 <<<<<<<, =======, >>>>>>> 줄을 제거하십시오. 둘째로, 주변 코드를 확인하십시오. 충돌이 있었다면 논리적인 오류도 주변에 남아 있을 가능성이 높습니다! 이 시점에서 x check를 실행하여 눈에 띄는 오류가 없는지 확인하는 것이 좋습니다.
충돌을 모두 해결했으면, 충돌이 있었던 파일들을 git add로 스테이징해야 합니다. 그런 다음 git rebase --continue를 실행하여, 충돌을 해결했으니 리베이스를 마무리하라고 Git에 알리십시오.
리베이스가 성공하면 git push --force-with-lease로 포크의 해당 브랜치를 업데이트하십시오.
최신 상태 유지하기
위 섹션은 작업을 리베이스하고 병합 충돌을 처리하는 방법을 구체적으로 안내합니다. 다음은 로컬 저장소를 업스트림 변경 사항에 맞춰 최신 상태로 유지하는 방법에 대한 일반적인 조언입니다:
로컬 main 브랜치에서 git pull upstream main을 정기적으로 실행하면 이 브랜치를 최신 상태로 유지할 수 있습니다. 기능 브랜치도 최신 상태로 유지하는 것이 좋습니다. 풀한 후에는 기능 브랜치를 체크아웃하고 리베이스할 수 있습니다:
git checkout main
git pull upstream main --ff-only # to make certain there are no merge commits
git rebase main feature_branch
git push --force-with-lease # (set origin to be the same as local)
병합 금지 정책에 따라 병합을 피하려면 git config pull.ff only를 사용하는 것이 좋습니다(이 설정은 로컬 저장소에만 적용됩니다). 이렇게 하면 매번 --ff-only나 --rebase를 넘기지 않아도 git pull할 때 Git이 병합 커밋을 만들지 않습니다.
main에서 git push --force-with-lease를 실행하여 기능 브랜치가 GitHub 쪽 상태와 동기화되어 있는지 다시 확인할 수도 있습니다.
고급 리베이스
커밋 스쿼시하기
커밋들을 서로 “스쿼시“하면 하나의 커밋으로 합쳐집니다. 이렇게 하면 히스토리가 단순해지며, 이는 장점이자 단점입니다. 변경이 어떤 단계를 거쳐 이루어졌는지 추적할 수 없게 되는 반면, 히스토리는 다루기 더 쉬워집니다.
rust-lang/rust 저장소의 PR에서 커밋을 스쿼시하는 가장 쉬운 방법은 PR에 댓글로 @bors squash 명령을 남기는 것입니다. 기본적으로 bors는 PR의 모든 커밋 메시지를 합쳐 스쿼시된 커밋 메시지로 만듭니다. 커밋 메시지를 직접 지정하려면 @bors squash msg="<commit message>"를 사용하십시오. 예를 들면 @bors squash msg="Improve diagnostics for missing lifetime parameter"와 같습니다.
로컬 git 작업으로 커밋을 스쿼시하려면 아래 내용을 계속 읽으십시오.
충돌이 없고 히스토리를 정리하기 위해 스쿼시만 하는 경우에는 git rebase --interactive --keep-base main을 사용하십시오. 이렇게 하면 PR의 분기 지점이 그대로 유지되므로, 여러 번의 리베이스에 걸쳐 무엇이 바뀌었는지 diff를 검토하기가 더 쉬워집니다.
스쿼시는 충돌 해석 과정의 일부로도 유용할 수 있습니다. 브랜치에 같은 코드를 연속해서 여러 번 고쳐 쓴 커밋이 있거나 리베이스 충돌이 매우 심각한 경우, git rebase --interactive main을 사용하면 이 과정을 더 세밀하게 제어할 수 있습니다. 이 명령을 사용하면 커밋을 건너뛰거나, 건너뛰지 않은 커밋을 편집하거나, 커밋이 적용되는 순서를 바꾸거나, 커밋들을 서로 “스쿼시“할 수 있습니다.
또는 다음과 같이 커밋 히스토리를 포기할 수도 있습니다:
# squash all the changes into one commit so you only have to worry about conflicts once
git rebase --interactive --keep-base main # and squash all changes along the way
git rebase main
# fix all merge conflicts
git rebase --continue
마지막 몇 개의 커밋만 함께 스쿼시하고 싶을 수도 있습니다. 해당 커밋들이 실제 변경이 아니라 “수정(fixup)“일 뿐인 경우가 그렇습니다. 예를 들어 git rebase --interactive HEAD~2를 사용하면 마지막 두 커밋만 편집할 수 있습니다.
git range-diff
리베이스를 마친 뒤 변경 사항을 푸시하기 전에, 이전 브랜치와 새 브랜치 사이의 변경 사항을 검토하는 것이 좋습니다. 이는 git range-diff main @{upstream} HEAD로 할 수 있습니다.
range-diff의 첫 번째 인자(이 경우 main)는 이전 브랜치와 새 브랜치를 비교할 때 기준이 되는 기준 리비전입니다. 두 번째 인자는 브랜치의 이전 버전입니다. 이 경우 @upstream은 GitHub에 푸시한 버전을 뜻하며, 이는 사람들이 풀 리퀘스트에서 보게 될 내용과 같습니다. 마지막으로 range-diff의 세 번째 인자는 브랜치의 새 버전입니다. 이 경우 HEAD이며, 이는 로컬 저장소에서 현재 체크아웃된 커밋입니다.
이와 동일한 축약형인 git range-diff main @{u} HEAD도 사용할 수 있다는 점에 유의하십시오.
일반적인 Git diff와 달리, range-diff 출력에서는 - 또는 + 옆에 또 다른 - 또는 +가 표시됩니다. 왼쪽 표시는 이전 브랜치와 새 브랜치 사이의 변경 사항을 나타내고, 오른쪽 표시는 여러분이 커밋한 변경 사항을 나타냅니다. 따라서 range-diff는 이전 diff와 새 diff 사이의 차이를 보여 주므로 “diff의 diff“라고 생각할 수 있습니다.
다음은 git range-diff 출력의 예시입니다(Git 문서에서 발췌):
-: ------- > 1: 0ddba11 Prepare for the inevitable!
1: c0debee = 2: cab005e Add a helpful message at the start
2: f00dbal ! 3: decafe1 Describe a bug
@@ -1,3 +1,3 @@
Author: A U Thor <author@example.com>
-TODO: Describe a bug
+Describe a bug
@@ -324,5 +324,6
This is expected.
-+What is unexpected is that it will also crash.
++Unexpectedly, it also crashes. This is a bug, and the jury is
++still out there how to fix it best. See ticket #314 for details.
Contact
3: bedead < -: ------- TO-UNDO
(터미널에서 보는 git range-diff 출력은 색상이 적용되므로 아마 이 예시보다 읽기 쉬울 것입니다.)
git range-diff의 또 다른 기능은 git diff와 달리 커밋 메시지도 비교한다는 점입니다. 이 기능은 여러 커밋 메시지를 수정할 때 올바른 부분을 변경했는지 확인하는 데 유용할 수 있습니다.
git range-diff는 매우 유용한 명령이지만, 출력 형식에 익숙해지기까지 시간이 걸릴 수 있다는 점에 유의하십시오. 이 명령에 관한 Git 문서, 특히 “Examples” 섹션도 유용할 것입니다.
병합 금지 정책
rust-lang/rust 저장소는 이른바 “리베이스 워크플로“를 사용합니다. 이는 PR에 병합 커밋을 허용하지 않는다는 뜻입니다. 따라서 로컬에서 git merge를 실행하고 있다면, 대신 리베이스를 해야 할 가능성이 높습니다. 물론 항상 그런 것은 아닙니다. git pull이 보통 수행하는 병합처럼 병합이 단순히 fast-forward라면 병합 커밋이 생성되지 않으므로 걱정할 필요가 없습니다. git config merge.ff only를 한 번 실행하면(이 설정은 로컬 저장소에 적용됩니다) 여러분이 수행하는 모든 병합이 이러한 유형이 되므로 실수할 일이 없습니다.
이렇게 결정한 데에는 여러 이유가 있으며, 다른 모든 결정과 마찬가지로 이 결정도 트레이드오프입니다. 주된 장점은 커밋 기록이 대체로 선형이라는 것입니다. 이 덕분에 bisect가 훨씬 간단해지고, 기록과 커밋 로그를 따라가고 이해하기가 훨씬 쉬워집니다.
리뷰를 위한 팁
참고: 이 섹션은 PR을 작성하는 것이 아니라 리뷰하는 것에 관한 내용입니다.
공백 숨기기
GitHub에는 공백 변경을 숨기는 버튼이 있어 유용하게 쓸 수 있습니다. 로컬에서 git diff -w origin/main을 사용하여 변경 사항을 볼 수도 있습니다.
PR 가져오기
로컬에서 PR을 체크아웃하려면 git fetch upstream pull/NNNNN/head && git checkout FETCH_HEAD를 사용할 수 있습니다.
GitHub의 CLI 도구를 사용할 수도 있습니다. GitHub는 PR에 로컬에서 체크아웃하는 명령을 복사해 붙여 넣을 수 있는 버튼을 표시합니다. 자세한 내용은 https://cli.github.com/를 참고하십시오.
GitHub dev 사용하기
GitHub 웹 UI의 대안으로, GitHub Dev는 저장소와 PR을 탐색할 수 있는 웹 기반 편집기를 제공합니다. URL에서 github.com을 github.dev로 바꾸거나 GitHub 페이지에서 .을 누르면 열 수 있습니다. 자세한 내용은 github.dev 편집기 문서를 참고하십시오.
큰 코드 영역 이동하기
파일 내에서 이루어진 대규모 이동에 대한 Git과 GitHub의 기본 diff 보기는 상당히 불편합니다. 모든 줄을 삭제된 것으로, 또 모든 줄을 추가된 것으로 표시하므로 각 줄을 직접 비교해야 합니다. Git에는 이동된 줄을 다른 색으로 표시하는 옵션이 있습니다:
git log -p --color-moved=dimmed-zebra --color-moved-ws=allow-indentation-change
자세한 내용은 --color-moved 문서를 참고하십시오.
range-diff
PR 작성자를 위한 관련 섹션을 참고하십시오. 이는 force-push된 코드를 비교하여 예상치 못한 변경이 없는지 확인할 때 유용합니다.
특정 파일의 변경 사항 무시하기
저장소에 있는 많은 대용량 파일은 자동으로 생성된 것입니다. 해당 파일들의 변경 사항을 무시한 diff를 보려면 다음 구문을 사용할 수 있습니다(예: Cargo.lock):
git log -p ':!Cargo.lock'
임의의 패턴도 지원됩니다(예: :!compiler/*). 패턴은 .gitignore와 같은 구문을 사용하며, 패턴임을 나타내기 위해 앞에 :를 붙입니다.
Git 서브모듈
참고: 서브모듈은 알아 두면 좋지만, rustc에 기여하기 위한 절대적인 전제 조건은 아닙니다. Git을 처음 사용한다면, 이 섹션을 읽기 전에 Git의 주요 개념에 먼저 익숙해지는 것이 좋습니다.
rust-lang/rust 저장소는 rust 저장소 안에서 다른 Rust 프로젝트를 사용하기 위한 방법으로 Git 서브모듈을 사용합니다. 예로는 Rust가 포크한 llvm-project, cargo, 그리고 stdarch와 backtrace 같은 라이브러리가 있습니다.
이러한 프로젝트는 별도의 Git(및 GitHub) 저장소에서 개발 및 유지 관리되며, 자체 Git 히스토리/커밋, 이슈 트래커, PR을 가지고 있습니다. 서브모듈을 사용하면 rust 저장소 안에 일종의 내장된 하위 저장소를 만들고, 이를 rust 저장소의 디렉터리처럼 사용할 수 있습니다.
llvm-project를 예로 들어 보겠습니다. llvm-project는 rust-lang/llvm-project 저장소에서 유지 관리되지만, rust-lang/rust에서 컴파일러가 코드 생성과 최적화를 위해 사용합니다. 우리는 이를 src/llvm-project 폴더에 서브모듈로 두어 rust에 가져옵니다.
서브모듈의 내용은 Git에서 무시됩니다. 서브모듈은 어떤 의미에서 저장소의 나머지 부분과 격리되어 있습니다. 하지만 cd src/llvm-project를 실행한 다음 git status를 실행해 보면:
HEAD detached at 9567f08afc943
nothing to commit, working tree clean
git의 관점에서 보면, 여러분은 더 이상 rust 저장소가 아니라 llvm-project 저장소에 있습니다. “detached HEAD” 상태, 즉 브랜치가 아니라 특정 커밋에 있다는 것을 알 수 있습니다.
이는 다른 의존성과 마찬가지로, 어떤 버전을 사용할지 제어할 수 있기를 원하기 때문입니다. 서브모듈은 바로 이것을 가능하게 합니다. 모든 서브모듈은 특정 커밋에 “고정“되며, 이 커밋은 수동으로 수정하지 않는 한 바뀌지 않습니다. llvm-project 디렉터리에서 git checkout <commit>을 사용한 뒤 rust 디렉터리로 돌아가면, 예를 들어 git add src/llvm-project를 실행하여 이 변경 사항을 다른 변경 사항과 마찬가지로 스테이징할 수 있습니다. (변경 사항을 커밋하도록 스테이징하지 않으면, x를 실행할 때 서브모듈을 자동으로 “업데이트“하면서 이전 커밋으로 되돌아가 변경 사항이 그냥 취소될 위험이 있다는 점에 유의하십시오.)
이러한 버전 선택은 보통 프로젝트 메인테이너가 수행하며, 이와 같은 모습입니다.
Git 서브모듈은 익숙해지는 데 시간이 좀 걸리므로, 아직 완전히 이해되지 않더라도 걱정하지 마십시오. 서브모듈을 직접 사용해야 하는 경우는 드물며, 다시 말하지만 Rust에 기여하기 위해 서브모듈에 대한 모든 것을 알 필요는 없습니다. 서브모듈이 존재한다는 것, 그리고 서브모듈은 Git이 우리 대신 깔끔하고 꽤 편리하게 처리해 주는 일종의 내장된 하위 저장소 의존성에 해당한다는 것만 알아 두십시오.
서브모듈 하드 리셋하기
때때로 (git status를 실행하면) 다음과 같은 상황을 마주칠 수 있습니다
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
(commit or discard the untracked or modified content in submodules)
modified: src/llvm-project (new commits, modified content)
그리고 git submodule update를 실행하려고 하면 다음과 같은 오류와 함께 심각하게 실패합니다
error: RPC failed; curl 92 HTTP/2 stream 7 was not closed cleanly: CANCEL (err 8)
error: 2782 bytes of body are still expected
fetch-pack: unexpected disconnect while reading sideband packet
fatal: early EOF
fatal: fetch-pack: invalid index-pack output
fatal: Fetched in submodule path 'src/llvm-project', but it did not contain 5a5152f653959d14d68613a3a8a033fb65eec021. Direct fetching of that commit failed.
(new commits, modified content)가 보이면 다음을 실행할 수 있습니다
git submodule foreach git reset --hard
그런 다음 git submodule update를 다시 시도하십시오.
git 서브모듈 deinit하기
그래도 해결되지 않으면 모든 git 서브모듈을 deinit해 볼 수 있습니다…
git submodule deinit -f --all
안타깝게도 때로는 어떤 이유로 로컬 git 서브모듈 설정이 완전히 엉망이 될 수 있습니다.
fatal: not a git repository: <submodule>/../../.git/modules/<submodule> 해결하기
때때로 도무지 알 수 없는 이유로 다음과 같은 상황을 마주칠 수 있습니다
fatal: not a git repository: src/gcc/../../.git/modules/src/gcc
이 경우 해당 서브모듈 경로, 즉 이 예시에서는 <submodule_path> = src/gcc에 대해 다음을 수행해야 합니다:
rm -rf <submodule_path>/.gitrm -rf .git/modules/<submodule_path>/config- 어떤 이유로
.gitconfig잠금이 고아 상태가 되었다면rm -rf .gitconfig.lock
그런 다음 ./x fmt와 같은 명령을 실행하여 bootstrap이 서브모듈 체크아웃을 대신 관리하도록 하십시오.
git blame 중에 커밋 무시하기
일부 커밋에는 기능은 변경하지 않는 대규모 서식 재정리 변경 사항이 포함되어 있습니다. 이러한 커밋은 .git-blame-ignore-revs를 통해 git blame이 무시하도록 지정할 수 있습니다:
.git-blame-ignore-revs를 무시할 커밋 목록으로 사용하도록git blame을 설정하십시오:git config blame.ignorerevsfile .git-blame-ignore-revsgit blame이 무시하기를 원하는 적절한 커밋을 추가하십시오.
.git-blame-ignore-revs에 추가하는 커밋에는 사람들이 해당 커밋이 무시되는 이유를 쉽게 파악할 수 있도록 주석을 포함하십시오.
@rustbot 완전 정복
@rustbot(triagebot이라고도 합니다)은 유틸리티 로봇입니다. 원래는 rust-lang 조직의 GitHub 멤버만 할 수 있는 작업을 모든 기여자가 할 수 있게 해 주는 용도로 주로 쓰입니다. rustc 기여자에게 가장 유용한 기능은 이슈 할당 요청과 레이블 재지정입니다.
이슈 할당 요청
아직 할당되지 않은 이슈를 작업하려면 그 이슈의 할당을 요청하십시오. 이슈 할당을 참고하십시오.
이슈 레이블 재지정
이슈나 PR의 레이블을 바꾸는 권한도 원래는 조직 멤버에게만 있습니다. 하지만 @rustbot을 사용하면 몇 가지 제한 안에서 이슈의 레이블을 직접 재지정할 수 있습니다. 이 기능은 주로 다음 두 가지 경우에 유용합니다.
이슈 분류 돕기: 이 글을 쓰는 시점에 Rust의 이슈 트래커에는 열린 이슈가 5,000개가 넘습니다. 그래서 이슈 트래커를 최대한 깔끔하게 유지하는 데는 레이블이 가장 강력한 도구입니다. 이슈를 분류하려고 이슈 트래커에서 몇 시간씩 보낼 필요는 없습니다. 하지만 이슈를 직접 열었고 레이블을 붙이는 것이 어렵지 않다면 얼마든지 직접 붙이십시오.
PR 상태 갱신하기: PR의 상태는 “상태 레이블“로 나타냅니다. 예를 들어 PR에 병합 충돌이 생기면 S-waiting-on-author 레이블이 자동으로 붙습니다. 이 경우 PR을 리베이스하기 전까지 리뷰어가 리뷰하지 않을 수 있습니다. 브랜치를 리베이스했다면 레이블을 직접 바꾸십시오. S-waiting-on-author 레이블을 제거하고 S-waiting-on-review를 다시 추가해야 합니다. 이 경우 @rustbot 명령은 다음과 같습니다.
@rustbot label -S-waiting-on-author +S-waiting-on-review
이 명령의 구문은 꽤 유연하므로, 이 명령을 호출하는 다른 변형도 있습니다. 레이블을 갱신하는 단축 명령도 몇 가지 있습니다. 예를 들어 @rustbot ready는 위 명령과 같은 일을 합니다. 자세한 내용은 레이블 지정에 관한 문서 페이지와 단축 명령을 참고하십시오.
기타 명령
@rustbot으로 무엇을 할 수 있는지 궁금하다면 문서를 확인하십시오. 이 문서는 봇의 레퍼런스이며, 봇이 업그레이드될 때마다 최신 상태로 갱신되어야 합니다.
@rustbot은 릴리스 팀이 관리합니다. 기존 명령에 대한 의견이나 새 명령에 대한 제안이 있다면 언제든지 Zulip에서 연락하거나 triagebot 저장소에 이슈를 등록하십시오.
LLM 활용하기
이 절은 LLM 활용에 관한 지침 목록이자 운영 정책의 요약입니다. 이 절은 정책 그 자체가 아닙니다. Forge를 참고하십시오. 둘이 서로 어긋나는 경우 Forge가 기준입니다.
LLM을 사용하여 코드를 작성하는 경우 LLM으로 생성한 코드 작성하기를 참고하십시오. LLM으로 생성한 코드를 리뷰하거나 LLM을 사용하여 리뷰하는 경우 LLM으로 리뷰하기를 참고하십시오.
LLM으로 생성한 코드 작성하기
LLM은 유용한 도구이지만, 오용하기 쉬운 도구이기도 합니다. 주요 위험은 감당하기 어려운 분량과 이해 부족입니다. PR을 작성할 때에는 반대편에 여러분의 변경 사항을 리뷰하고 이해해야 하는 사람이 있다는 점, 그리고 프로젝트의 다른 사람들이 앞으로 수년 동안 여러분의 코드를 읽어야 한다는 점을 명심하십시오. 변경 사항을 작고, 목적이 분명하며, 품질 높게 만들어 저희를 도와주십시오.
다음 인용구를 명심하십시오:
프로그램은 사람이 읽을 수 있도록 작성되어야 하며, 기계가 실행하는 것은 부차적인 일일 뿐입니다. — Harold Abelson, Gerald Jay Sussman, Structure and Interpretation of Computer Programs
규칙
코드를 작성하기 전에
무엇보다 먼저, 여러분의 PR을 자원하여 리뷰해 줄 리뷰어를 찾으십시오. 리뷰어를 어디서 찾아야 할지 모르겠다면, Zulip의 #llm-mentoring에서 문의하십시오. 첫 메시지에는 다음 내용을 담아야 합니다:
- 여러분에게 적합한 이슈를 찾을 수 있도록, 여러분의 관련 경험
- 작업하고 싶은 문제(또는 문제의 종류)
- (선택 사항) 지금까지 생각해 둔 해결 방안
- (선택 사항) 해결 방안을 어떻게 테스트할 계획인지
멘토는 여러분을 돕기 위해 있습니다. 멘토와 일찍 이야기하면 헛수고를 피하는 데 도움이 됩니다.
작업하는 동안
문서 주석, // SAFETY 주석, 진단 메시지 문구, 건전성(soundness)에 중요한 코드는 직접 작성하십시오. 앞서와 마찬가지로, LLM을 사용하여 작업을 리뷰할 수는 있지만 처음부터 작성하게 해서는 안 됩니다. 무엇이 건전성에 중요한 코드에 해당하는지 모르겠다면, 리뷰어와 논의하십시오.
PR을 열 때
아래의 공개 지침에 따라 LLM 사용 여부를 공개하십시오. 공개 내용은 직접 작성하십시오. 직접 작성한 공개 내용을 LLM으로 비공개로 리뷰하는 것은 허용되지만, 초안을 작성하거나 다시 쓰게 해서는 안 됩니다.
PR 설명과 댓글은 직접 작성하십시오. 저희는 여러분의 에이전트가 아니라 여러분의 이야기를 듣고 싶습니다. LLM으로 생성한 PR 설명은 금지됩니다. LLM으로 생성한 GitHub 댓글은 금지됩니다.
지침
코드를 작성하기 전에
한 번에 PR 하나씩 시작하십시오. 변경 사항은 여러분과 리뷰어가 모든 부분을 이해할 수 있을 만큼 작게 유지하십시오. 천천히 진행하십시오.
E-easy 이슈에는 LLM을 사용하지 마십시오. 이러한 이슈는 여러분이 직접 코드를 작성하도록 마련된 것입니다. E-mentor 이슈를 작업하기 전에는 먼저 문의하십시오. 멘토가 LLM이 생성한 코드로 작업하기를 원하지 않을 수도 있습니다.
이것이 유용하고 범위가 적절한 변경인지 판단하십시오. 예를 들면 다음과 같습니다.
- 관련 이슈와 PR을 검색하십시오.
- 관련 코드, 테스트, git 기록, Zulip 논의를 찾아보십시오.
- 여러 영역에 걸친 변경이라면 풀 리퀘스트 안내를 참고하십시오.
- 문제를 해결하는 가장 작은 변경을 만드십시오. 관련 없는 리팩터링이나 정리 작업과 합치지 마십시오.
작업하는 동안
버그를 수정할 때는 테스트가 변경 전에는 실패하고 변경 후에는 성공하는지 확인하십시오. 테스트 절차는 새 테스트 추가하기와 모범 사례를 참고하십시오. 테스트는 반드시 필요합니다. 기존 테스트든 여러분이 새로 작성한 테스트든 상관없습니다. 테스트되지 않은 LLM PR은 병합되지 않습니다.
대량의 이름 변경이나 재작성에는 ast-grep과 같은 적절한 구문 재작성 도구를 사용하는 편을 강력히 권장합니다. 해당 도구에 쓸 명령을 생성하는 데는 LLM을 사용해도 되지만, LLM으로 재작성을 직접 수행하는 것은 매우 신중해야 합니다.
코드를 작성할 때 성능을 고려하십시오.
의존성을 추가하기 전에 신중히 생각하십시오. 새 의존성에 관한 안내를 참고하십시오.
여러분의 이해가 맞는지 기존 코드, 문서, 테스트와 대조하여 확인하십시오. LLM 자신에게 관련 자료를 읽도록 지시하면 더 나은 조언을 얻을 수 있습니다. LLM을 신뢰할 수 있는 정보원으로 삼지 마십시오.
유지 보수하기 쉬운 코드 작성
생성된 코드는 최종 결과물이 아니라 초안으로 취급하십시오. 정확성 및 유지 보수성 규칙을 따르십시오.
불필요한 추상화와 호환성 계층을 피하십시오. Rustc에는 안정된 API가 없습니다. 내부 컴파일러 API의 하위 호환성을 유지할 필요는 없습니다.
커밋 구조
“PR 구성 방법”을 참고하십시오. 커밋 메시지는 LLM이 아니라 여러분이 직접 작성해야 합니다.
PR을 열기 전에
PR을 열기 전에 자신의 PR을 직접 검토하십시오. 이치에 맞습니까? PR의 목표가 무엇인지 알 수 있습니까? 그 목표를 달성합니까?
오래되었거나 프로토타이핑용인 코드와 디버깅 코드를 제거하십시오.
에이전트와의 대화만이 아니라 전체 diff를 다시 읽으십시오. 리뷰어는 여러분의 대화가 아니라 코드를 보게 됩니다.
변경 사항이 제대로 동작하는지 확인하려면 테스트를 실행하십시오. 어떤 UI 테스트를 실행했는지 PR 설명에 절대 적지 마십시오. CI가 어차피 실행하므로 불필요한 정보일 뿐입니다. 수동 테스트나 벤치마킹을 수행했다면 그 내용은 꼭 적으십시오. 다만 모든 LLM PR에는 자동화된 테스트가 있어야 한다는 점에 유의하십시오.
진단 스냅숏을 검토하십시오. 단순히 --bless로 덮어써서 넘어가지 마십시오.
변경 사항을 게시하기 전에 다른 모델을 사용하여 적대적 로컬 리뷰를 수행할 것을 권장합니다. 그렇더라도 자신의 변경 사항을 직접 검토할 책임은 여전히 여러분에게 있습니다.
자신의 변경 사항 이해하기
여러분이 자신의 변경 사항과 그 엣지 케이스를 이해하고 설명할 수 있기를 바랍니다. LLM에게 묻는 것이 출발점이 될 수는 있지만, 직접 설명하는 것과 같지는 않습니다.
PR을 열기 전에 자신의 변경 사항을 스스로에게 설명해 보십시오. 예를 들어 스스로에게 다음과 같이 물어보십시오:
- 원래 버그는 무엇입니까? 언제 발생합니까? 얼마나 심각합니까? 원인은 무엇입니까?
- 왜 이것이 올바른 수정입니까? 다른 수정 방법이 있습니까? 각각의 장점이나 단점은 무엇입니까?
- 엣지 케이스가 있습니까? 여러분의 코드가 그것들을 처리합니까?
- 코드에 기존 불변 조건이 있습니까? 그 불변 조건을 유지했습니까?
- 변경되지 않는 동작은 무엇입니까? 어떤 테스트가 그것을 입증합니까?
- 여러분의 테스트는 왜 그 버그를 유발합니까?
- 아직 확신하지 못하는 부분은 무엇입니까?
확신이 없는 것도, 도움을 요청하는 것도 괜찮습니다. 잘못 추측하신 뒤 어디서 잘못되었는지 역추적해야 하는 상황보다, 확신이 없으셔서 저희가 도와드리는 편이 훨씬 낫습니다.
공개 지침
LLM 사용의 범위와 목적을 공개하십시오. 어떤 모델을 사용했는지는 중요하지 않지만, LLM을 아이디어를 구현하는 데 사용했는지 아니면 아이디어를 떠올리는 데 사용했는지는 중요합니다.
좋은 예시:
LLM 사용 공개: 프로파일링 데이터를 확인한 뒤 세 개의 커밋을 직접 작성했습니다. 제출하기 전에 LLM을 사용하여 커밋을 검토했습니다. LLM이
ImplString::is_negative가 더 이상 사용되지 않는다는 점을 찾아냈고, 그래서 해당 필드를 직접 제거했습니다.
LLM의 도움을 받아 작성했으며, LLM은 다음 작업을 수행했습니다:
- Fastly와 CloudFront 헤더를 비교 검사하여 캐시 히트 누락의 원인이 무조건적인
return(pass)임을 추적했고,- 해당 스니펫이 추가된 이유를 파악하기 위해 git 히스토리를 검토했으며,
- VCL 변경을 수행했습니다.
나쁜 예시:
🤖 Claude Code로 생성됨
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LLM 정책
Forge를 참조하십시오.
LLM으로 리뷰하기
LLM으로 코드 리뷰하기
- 작성 중인 언어에 린터나 포매터처럼 더 신뢰할 수 있는 도구가 이미 있다면, LLM 대신 또는 LLM과 함께 그 도구를 사용할 것을 강력히 권장합니다.
- 거짓 양성과 사소한 부분에 대한 과도한 집중은 흔하면서도 사람을 지치게 하는 실패 유형이므로, 이를 줄이도록 LLM 리뷰 도구를 설정하십시오.
- 가능하면 LLM에게 린터를 생성하거나 설정하도록 요청한 다음, 그 린터를 실행하도록 지시하십시오. 이렇게 하면 토큰 비용을 절약할 수 있을 뿐만 아니라, LLM을 사용하지 않는 사람도 분석을 실행할 수 있습니다. 예를 들어, 명령 실행을 감싸는 코드베이스 전용 래퍼가 있다면, LLM에게 래퍼를 사용해야 할 곳을 찾게 하는 대신 clippy가
Command::new를 금지하도록 설정하십시오. - LLM은 때때로 LLM이 생성한 출력, 특히 같은 모델이 생성한 출력을 선호합니다. LLM 리뷰는 참고용으로 취급하고, 변경 사항을 만든 모델을 유일한 리뷰어로 삼지 마십시오.
LLM이 만든 코드 리뷰하기
먼저 PR에 새로운 llm-assisted 레이블을 추가하십시오.
규칙
저희는 작성자뿐 아니라 모든 사람이 새 정책을 따를 것을 기대합니다. 즉, llm-assisted PR이 정책에서 허용하지 않는 영역을 건드리는지 확인하는 것은 여러분의 책임입니다. 작성자에게 LLM이 생성한 코드 없이 다시 작성하도록 요청할 수도 있으며, 그 경우 이 절은 적용되지 않습니다.
현재 다음 영역은 금지되어 있습니다:
- 건전성(soundness)에 영향을 주는 코드. 작성자가 해당 분야에 경험이 있는 조직 구성원이 아니라면, 반드시 PR을 닫아야 합니다.
- 진단 메시지. 사용자에게 보이는 모든 진단 메시지는 사람이 직접 작성해야 합니다.
- 문서. 모든 공개 문서 주석과 모든
// SAFETY주석은 사람이 직접 작성해야 합니다.
“건전성에 영향을 주는 코드“는 들리는 것보다 더 넓기도 하고 더 좁기도 합니다. 컴파일러의 거의 모든 부분이 건전성과 관련되어 있다는 점에서 더 넓으며, rust-lang/rust에는 컴파일러가 아닌 부분(라이브러리, bootstrap, compiletest, rustdoc, CI 등)이 상당히 많다는 점에서 더 좁습니다.
확신이 서지 않는다면 다음 기준을 권장합니다: 잘못된 코드가 잘못되어 보이지 않는 컴파일러 부분에는 LLM이 생성한 코드를 허용하지 마십시오. 결국 이는 리뷰어인 여러분의 판단에 달려 있습니다.
여러분은 여전히 자신의 r+ 권한을 존중해야 합니다. 메인테이너가 LLM PR을 리뷰하기를 원하지 않더라도, 해당 코드 부분에 확신이 없다면 PR을 병합하지 마십시오.
지침
사람들에게 #llm-mentoring을 적극적으로 안내하십시오. 품질이 낮은 PR은 닫고 작성자에게 정책을 따르도록 요청하는 방식으로 처리하십시오. 판단이 애매한 PR은 작업을 여러분에게 떠넘기지 말고 작성자가 직접 하도록 요청하는 방식으로 처리하십시오. 예를 들어 버그를 재현하거나, 변경 사항을 자신의 말로 설명하거나, 관련된 엣지 케이스를 찾아내거나, 테스트를 추가하거나 그 타당성을 설명하도록 요청하십시오.
여러 PR에서 같은 수정을 반복해서 제안하고 있다면, 그 내용을 dev-guide에 추가하는 것을 고려하십시오.
사용 사실 미공개
LLM 사용 사실을 밝히지 않았는데 “명백히” LLM으로 작성된 PR을 발견하면, 이를 일방적으로 닫을 수 있습니다. 다만 그렇게 해야 할 책임은 없습니다.
다음 문구를 사용할 것을 권장합니다:
This PR appears to be LLM-generated without disclosing use of an LLM, so I am going to close this PR.
You are welcome to open additional PRs as long as they follow our [policy][forge-page].
For more information, see [#llm-mentoring] on Zulip.
[#llm-mentoring]: https://rust-lang.zulipchat.com/join/rlfvpemsaacs3pfi6kwqnqjb/
[forge-page]: https://forge.rust-lang.org/policies/llm-usage.html
“명백한” LLM 사용 징후의 예는 다음과 같습니다:
- 완전히 틀렸거나 코드와 일치하지 않는 PR 설명.
- 실행한 테스트를 정확하게 명시하거나(예:
./x test --stage 1 tests/ui/<the-new-test-name>.rs)git diff --check와 같이 쓸모없는 테스트를 언급하는 PR 설명. - 리뷰어의 질문에 대한 답변 중 위 범주 중 하나에 해당하는 것.
LLM으로 작성된 PR을 찾아낼 의무는 없습니다. 탐정 노릇을 할 필요는 없습니다.
PR 템플릿에는 “LLM을 사용했습니까?“라는 질문이 포함되므로 이런 일은 드물게 발생합니다. 작성자가 이 질문에 답하지 않고 삭제했다면, 아무것도 묻지 않고 PR을 닫아도 됩니다.
요청한 리뷰어 누락
PR이 LLM 사용 사실을 밝혔지만 실험 가이드라인에 따라 리뷰어를 지정하지 않았다면, 마찬가지로 닫을 수 있습니다:
You've opened an LLM-generated PR, but it's in the normal review queue, which breaks our [policy][experiment guidelines].
I am going to close this PR.
Please do not re-open it until you find a project member who has volunteered to review it.
For more information, see [#llm-mentoring] on Zulip.
[#llm-mentoring]: https://rust-lang.zulipchat.com/join/rlfvpemsaacs3pfi6kwqnqjb/
[forge-page]: https://forge.rust-lang.org/policies/llm-usage.html
테스트 누락, 낮은 품질 또는 자체 리뷰 미실시
PR이 명백히 리뷰할 준비가 되지 않았다면, 리뷰하지 않아도 됩니다. PR을 대충 훑어보고 작성자에게 “리뷰하기 전에 테스트를 추가해야 합니다“라고 말하기만 해도 괜찮습니다. 훑어보는 중에 PR의 접근 방식이 명백히 잘못되었다는 것을 알게 되면, PR을 닫고 작성자에게 새 PR을 열기 전에 #llm-mentoring 채널에서 여러분과 이야기하라고 말해도 괜찮습니다.
따라 하기: 새 테스트 추가하기
이 장에서는 rust#59333과 rust#161442를 예로 들어, Rust에 처음 기여할 때 해 볼 수 있는 작은 변경의 예를 보여 드립니다.
작업할 E-needs-test 이슈 찾기
시도해 볼 수 있는 작업 목록은 “무엇을 작업해야 합니까?”를 참고하십시오. 여기서는 E-needs-test 이슈인 rust#59333을 골랐습니다. 이 이슈에는 이슈 안에 바로 예제가 있어 버그를 최소화하는 추가 작업을 하지 않아도 되므로 특히 적합합니다. 물론 최소 예제가 없는 E-needs-test 이슈를 맡아 최소 예제를 만드는 것도 언제나 큰 도움이 됩니다.
이슈 재현하기
이 예제는 2019년, 즉 (이 글을 쓰는 시점 기준으로) 무려 7년 전에 게시되었습니다. 그동안 컴파일러에서는 상당히 많은 것이 바뀌었습니다. 테스트가 여전히 정확한지 확인하려면 가장 최신 컴파일러로 재현하십시오. nightly 브랜치로 설정한 play.rust-lang.org나 rustc nightly로 설정한 rust.godbolt.org를 사용하면 쉽게 재현할 수 있습니다. Playground에서는 할 수 없는 복잡한 설정이 필요하다면 로컬에서 rustc +nightly를 사용할 수도 있습니다.
이슈를 테스트로 변환하기
빠른 시작에 설명된 대로 rust-lang/rust 저장소를 체크아웃하고 설정하십시오.
$ git clone https://github.com/rust-lang/rust
$ cd rust
$ ./x setup compiler
$ ./x build library
여기서는 수정하려는 버그가 컴파일러와 관련되어 있으므로 기본 프로필로 compiler를 사용합니다. 표준 라이브러리에 단위 테스트를 추가하는 경우라면 ./x setup library를 사용합니다.
이 과정에서 .git/hooks/pre-push 검사 설정도 제안합니다. 이 설정은 선택 사항이지만 권장합니다.
또한 ./x build library로 백그라운드에서 빌드를 시작했습니다. 안타깝게도 Rust는 빌드하는 데 시간이 꽤 오래 걸리므로, 빌드를 일찍 시작해 두면 다른 작업을 하는 동안 백그라운드에서 빌드가 진행됩니다.
새 테스트를 추가하는 방법은 UI 테스트를 참고하십시오. 드물게는 run-make 테스트나 그보다 더 특수한 종류의 테스트가 필요할 수도 있습니다. 자세한 내용은 Compiletest를 참고하십시오.
이 경우 테스트는 상당히 간단합니다.
// Save this file to `tests/ui/lint/dead-code/type-alias-used-in-impl-59333.rs`.
//@ check-pass
//! Regression test for <https://github.com/rust-lang/rust/issues/59333>.
//! A type alias used only as (part of) the self type of an impl was
//! incorrectly flagged as dead code.
#![deny(dead_code)]
struct Runner;
type RuntimeImpl = Runner;
trait Runtime {
fn run(&mut self);
}
impl Runtime for &mut RuntimeImpl {
fn run(&mut self) {}
}
struct Walker;
type WalkerImpl = Walker;
trait Walk {
fn walk(&self) {}
}
impl Walk for WalkerImpl {}
fn main() {
let mut runner = Runner;
(&mut runner).run();
Walker.walk();
}
여기 나온 세부 사항은 대부분 그다지 중요하지 않지만, 맨 위의 //@ check-pass와 #![deny(dead_code)]에 주목하십시오. 이 둘을 함께 쓰면 이 파일을 컴파일할 때 컴파일러가 dead_code 린트를 내보내지 않는지 확인할 수 있습니다.
또한 “Regression test for …”(…에 대한 회귀 테스트)라는 주석에도 주목하십시오. 이 주석은 테스트가 무엇을 하는지, 그리고 그 동작을 더 간단하게 테스트할 방법이 있는지 리뷰어가 이해하는 데 도움이 되므로 리뷰어에게 매우 유용합니다. 테스트 설명은 최대한 빠짐없이 작성하십시오.
테스트를 실행하십시오. rust#161442는 테스트 이름을 tests/ui/lint/dead-code/type-alias-used-in-impl-59333.rs로 지었으므로 다음과 같이 실행하면 됩니다.
./x test tests/ui/lint/dead-code/type-alias-used-in-impl-59333.rs
테스트가 통과하면 준비가 끝난 것입니다.
PR 열기
PR 생명 주기의 지침을 따르십시오.
먼저, 앞에서 설정하지 않았다면 푸시 전 검사를 실행하십시오:
./x test tidy
그런 다음 PR을 여십시오:
git switch --create issue-59333-test
git add tests/ui/lint/dead-code/type-alias-used-in-impl-59333.rs
git commit
git remote add personal https://github.com/YOUR_USERNAME_HERE/rust.git
git push --set-upstream personal issue-59333-test
리뷰와 피드백
리뷰와 병합에 관한 정보는 PR 생명 주기를 참고하십시오.
새로운 언어 기능 구현하기
컴파일러에 중요한 새 기능을 구현하려면 모든 것이 순조롭게 진행되도록 이 절차를 거쳐야 합니다.
참고: 이 절은 언어 기능에 관한 것입니다. 다른 절차를 따르는 라이브러리 기능에는 해당하지 않습니다.
언어 변경을 제안하는 방법은 Rust 언어 설계 팀의 절차도 참고하십시오.
새로운 기능
Rust는 강력한 하위 호환성을 보장합니다. 따라서 새로운 기능을 stable Rust에 곧바로 구현할 수는 없습니다. 대신 stable, 베타, nightly라는 3개의 릴리스 채널을 운영합니다. Rust의 기차 릴리스 모델에 관한 자세한 내용은 The Rust Book을 참고하십시오.
- Stable: 일반적인 용도로 쓰는 최신 안정 릴리스입니다.
- 베타: 다음 릴리스입니다(6주 이내에 stable이 됩니다).
- Nightly: 저장소의
main브랜치를 따릅니다. 불안정 기능을 사용하도록 의도된 유일한 채널이며, 불안정 기능은 옵트인 방식의 기능 게이트를 통해 사용합니다.
호환성을 깨는 변경
호환성을 깨는 변경은 개발 가이드에 전용 절이 따로 있습니다.
주요 변경
“What proposal approval do I need?”를 참고하십시오. 해당 문서에 나오는 용어의 정의는 “Proposals”를 참고하십시오.
확신이 서지 않을 때는 Zulip에서 질문하십시오. 결국 병합되지 않을 PR에 많은 노력을 쏟는다면 안타까운 일입니다!
@rfcbot FCP 절차
변경 사항이 작고, 논란의 여지가 없고, 호환성을 깨지 않으며, 사용자가 관찰할 수 있는 어떤 방식으로도 stable 언어에 영향을 주지 않고, 새로운 불안정 기능도 추가하지 않는다면, PR을 작성하고 해당 코드 영역을 잘 아는 사람에게 r+를 받는 것만으로 충분합니다. 그렇지 않다면 더 많은 절차가 필요합니다. 컴파일러 내부 작업이라 하더라도 팀의 나머지 구성원과 합의하지 않은 채 논란의 여지가 있는 변경을 밀어붙이는 것은 좋지 않습니다(“분산 시스템“의 관점에서는 여러분이 모르는 부분을 망가뜨리지 않기 위해서이고, 사회적 관점에서는 PR 분쟁을 피하기 위해서입니다).
팀의 합의가 필요한 변경에는 최종 의견 수렴 기간(final comment period, FCP)을 제안하는 절차를 사용합니다. 해당 팀의 구성원이 아니라면(따라서 @rfcbot 권한이 없다면) 팀 구성원에게 FCP를 시작해 달라고 요청하십시오. 그 구성원 자신에게 우려 사항이 없다면 FCP를 시작해 주어야 합니다.
FCP 절차는 합의가 필요한 경우에만 필요합니다. 변경 사항에 대해 합의를 요구하는 절차가 없고 누구도 문제 삼지 않으리라 생각된다면 r+만으로 진행해도 괜찮습니다. 예를 들어 컴파일러 개발이나 표준 라이브러리에서 사용할 목적으로, 예약된 컴파일러 내부 rustc_ 네임스페이스에 속한 불안정한 명령줄 플래그나 속성을 추가하거나 수정하는 일은 nightly 생태계에서 널리 쓰이지 않으리라 예상되는 한 FCP 없이 진행해도 괜찮습니다. 일부 팀에는 이런 상황에서 사용하는 더 가벼운 절차가 있습니다. 예를 들어 컴파일러 팀은 완전한 합의 없이도 지지와 피드백을 모으는 가벼운 방법으로 주요 변경 제안(Major Change Proposal, MCP)을 제출할 것을 권장합니다.
FCP를 제안하기 위해 구현이 r+를 받을 만큼 완전히 준비되어 있을 필요는 없지만, 여러분이 무엇을 말하는지 사람들이 알 수 있도록 적어도 개념 증명(proof of concept) 정도는 갖추는 것이 일반적으로 좋습니다.
FCP가 제안되면 팀의 모든 구성원이 FCP를 승인해야 합니다. 모든 구성원이 승인하면 누구나 의견을 남길 수 있는 10일간의 “최종 의견 수렴 기간”(이름도 여기에서 유래했습니다)이 이어지며, 이 기간에 아무런 우려도 제기되지 않으면 해당 PR/이슈는 FCP 승인을 받습니다.
기능 작성의 실무 절차
기능을 제대로 동작하도록 구현하려면 거쳐야 할 몇 가지 “절차상의” 관문이 있습니다.
경고 주기
경우에 따라 기능이나 버그 수정이 일부 엣지 케이스에서 기존 프로그램을 깨뜨릴 수 있습니다. 그런 경우에는 crater 실행으로 영향을 평가하고, 필요하다면 에디션 게이트 린트에 사용되는 것과 유사한 향후 호환성 린트를 추가하는 것이 좋습니다.
안정성
우리는 Rust의 안정성을 중요하게 여깁니다. stable에서 동작하고 실행되는 코드는 (대부분) 깨져서는 안 됩니다. 그렇기 때문에 우리는 팀의 합의와 코드 리뷰만으로 기능을 세상에 공개하고 싶지 않습니다. nightly에서 해당 기능을 사용하며 실제 경험을 쌓고자 하며, 그 경험을 바탕으로 기능을 변경하고 싶을 수도 있습니다.
이를 가능하게 하려면 사용자가 실수로 새 기능에 의존하지 않도록 해야 합니다. 그렇지 않으면, 특히 실험에 시간이 걸리거나 지연되어 기능이 릴리스 열차를 타고 stable에 도달하게 되면, 해당 기능은 사실상 안정화된 상태가 되어 사람들의 코드를 깨뜨리지 않고는 변경할 수 없게 됩니다.
이를 위해 모든 새 기능이 기능 게이트로 보호되도록 합니다. 즉, 새 기능은 기능 게이트(#[feature(foo)])를 활성화하지 않으면 사용할 수 없으며, 기능 게이트는 stable/베타 컴파일러에서는 활성화할 수 없습니다. 기술적인 세부 사항은 코드에서의 안정성 절을 참고하십시오.
결국 기능을 사용하며 충분한 경험을 쌓고, 필요한 변경을 마치고, 만족스러운 상태가 되면, 여기에 설명된 안정화 절차를 통해 기능을 세상에 공개합니다. 그때까지 기능은 확정된 것이 아닙니다. 기능의 모든 부분이 변경될 수 있으며, 기능이 완전히 다시 작성되거나 제거될 수도 있습니다. 기능은 오랜 기간 불안정한 상태로 변경 없이 유지되었다는 이유만으로 지위를 보장받지 않습니다.
추적 이슈
불안정 기능의 상태, nightly에서 그 기능을 사용하며 얻는 경험, 그리고 기능의 안정화를 가로막는 블록 요인이 되는 우려 사항을 추적하기 위해 모든 기능 게이트에는 추적 이슈가 필요합니다. 해당 기능과 관련된 이슈와 PR을 만들 때는 이 추적 이슈를 참조하고, 기능의 진행 상황에 관한 업데이트가 있으면 추적 이슈에 게시하십시오.
승인된 RFC나 승인된 언어 실험의 일부인 기능의 경우, 해당 추적 이슈를 사용하십시오.
그 밖의 기능에 대해서는 해당 기능의 추적 이슈를 만드십시오. 이슈 제목은 “Tracking issue for YOUR FEATURE“여야 합니다. “Tracking Issue”(추적 이슈) 이슈 템플릿을 사용하십시오.
언어 실험
컴파일러에 반영되려면, 언어에 사용자가 볼 수 있는 영향을 미치는 기능은 (불안정한 기능이라 하더라도) 승인된 RFC의 일부이거나 승인된 언어 실험이어야 합니다.
새로운 언어 실험을 제안하려면 rust-lang/rust에 동기와 의도한 해결책을 설명하는 이슈를 여십시오. 제안이 승인되면 이 이슈가 실험의 추적 이슈가 되므로, 추적 이슈 템플릿을 사용하면서 이러한 다른 세부 사항도 함께 포함하십시오. 해당 이슈를 lang 팀에 지명하고 @rust-lang/lang과 @rust-lang/lang-advisors를 CC하십시오. 실험이 승인되면 추적 이슈에 B-experimental 표시가 붙습니다.
언어 실험과 관련된 기능 플래그는 해당 기능에 대한 RFC가 승인될 때까지 incomplete로 표시해야 합니다.
코드에서의 안정성
새로운 불안정 기능을 구현하려면 아래 단계를 따라야 합니다.
-
추적 이슈를 열거나 확인하십시오. 승인된 RFC나 승인된 언어 실험의 일부인 기능의 경우, 해당 추적 이슈를 사용하십시오.
추적 이슈에
C-tracking-issue레이블과 관련F-feature_name레이블을 붙이십시오(필요하면 해당 레이블을 새로 만드십시오). -
기능 게이트의 이름을 정하십시오(RFC가 있는 경우 RFC에 있는 이름을 사용하십시오).
-
rustc_span/src/symbol.rs의Symbols {...}블록에 기능 이름을 추가하십시오.이 블록은 알파벳 순서를 유지해야 한다는 점에 유의하십시오.
-
rustc_feature/src/unstable.rs의 불안정declare_features블록에 기능 게이트 선언을 추가하십시오./// description of feature (unstable, $feature_name, "CURRENT_RUSTC_VERSION", Some($tracking_issue_number))아직 추적 이슈를 열지 않았다면(예를 들어 기능이 수락될 가능성이 있는지 먼저 피드백을 받고 싶은 경우) 임시로
None을 사용할 수 있습니다. 다만 PR이 병합되기 전에 반드시 갱신하십시오!예를 들면 다음과 같습니다.
/// Allows defining identifiers beyond ASCII. (unstable, non_ascii_idents, "CURRENT_RUSTC_VERSION", Some(55467)),기능의 타입을
incomplete로 설정하면 해당 기능을 미완성으로 표시할 수 있으며, 이 경우 기본적으로 경고를 내는incomplete_features린트가 발생합니다./// Allows deref patterns. (incomplete, deref_patterns, "CURRENT_RUSTC_VERSION", Some(87121)),언어 실험과 관련된 기능 플래그는 해당 기능에 대한 RFC가 수락될 때까지
incomplete로 표시해야 합니다.의미적 병합 충돌을 피하려면
1.70이나 다른 명시적인 버전 번호 대신CURRENT_RUSTC_VERSION을 사용하십시오. -
기능 게이트가 설정되지 않은 경우에는 새 기능을 사용하지 못하도록 막으십시오. 컴파일러의 대부분의 위치에서
tcx.features().$feature_name()표현식으로 이를 확인할 수 있습니다.기능 게이트가 설정되지 않은 경우, 상황에 맞게 기능 도입 이전의 동작을 유지하거나 오류를 발생시켜야 합니다. 오류에는 일반적으로
rustc_session::errors::feature_err를 사용해야 합니다. 오류를 추가하는 예시는 #81015를 참고하십시오.새 구문을 도입하는 기능에는 대신 확장 전 게이팅을 사용해야 합니다. 파싱 중에 새 구문이 파싱되면
self.psess.gated_spans.gate(sym::my_feature, span)를 통해 현재 크레이트의GatedSpans에 심볼을 삽입해야 합니다.게이트된 스팬에 삽입된 후에는 실제로 기능을 거부하는
rustc_ast_passes::feature_gate::check_crate함수에서 해당 스팬을 검사해야 합니다. 정확히 어떻게 게이트되는지는 기능의 구체적인 종류에 따라 다르지만, 대부분gate_all!()매크로를 사용하게 됩니다. -
tests/ui/feature-gates/feature-gate-$feature_name.rs를 만들어 기능 게이트 없이는 해당 기능을 사용할 수 없음을 확인하는 테스트를 추가하십시오../x test tests/ui/feature-gates/ --bless를 실행하면 대응하는.stderr파일을 생성할 수 있습니다. -
src/doc/unstable-book/src/language-features/$feature_name.md에 불안정 기능 책(unstable book)의 섹션을 추가하십시오. -
새 기능에 대한 테스트를 많이 작성하십시오. 가능하면
tests/ui/$feature_name/에 작성하십시오. 테스트가 없는 PR은 수락되지 않습니다! -
PR 리뷰를 받고 병합하십시오. 이제 Rust에 기능을 성공적으로 구현했습니다!
불안정 컴파일러 플래그 추가하기
컴파일러 내부 실험, 진단, 디버깅 보조 기능, 임시 구현 제어에는 언어 기능 게이트보다 -Z 플래그가 더 적합할 수 있습니다. 그래도 사용자에게 노출되는 언어 기능, 특히 크레이트가 소스 코드에서 옵트인해야 하는 경우에는 여전히 기능 게이트를 우선적으로 사용해야 합니다.
새 플래그를 추가하기 전에 컴파일러 팀의 컴파일러 플래그 지침을 확인하십시오. 일부 플래그, 특히 nightly 사용자 워크플로에서 직접 사용하거나 더 넓은 생태계에서 사용하도록 의도된 플래그는 구현 PR에 대한 r+ 외에 추가 승인이 필요할 수 있습니다.
새 -Z 플래그를 추가할 때는 다음을 따르십시오.
-
compiler/rustc_session/src/options.rs의UnstableOptions에 옵션을 추가하십시오. 옵션 이름은 구조체에서는 snake_case로 작성되며, 명령줄에서는 kebab-case로 노출됩니다. 파서, 기본값, 의존성 추적 마커를 신중하게 선택하십시오. 진단이나 디버깅 출력에만 영향을 주는 플래그에는[UNTRACKED]를 사용하고, 플래그를 변경했을 때 컴파일 결과가 달라질 수 있는 경우에는[TRACKED]를 사용하십시오. -
compiler/rustc_interface/src/tests.rs의 옵션 테스트를 갱신하십시오.options.rs에는UnstableOptions목록 근처에 함께 동기화해야 하는 파일들에 대한 짧은 체크리스트도 있습니다. -
옵션은
sess.opts.unstable_opts.$flag_name또는tcx.sess.opts.unstable_opts.$flag_name을 통해 사용하십시오. bootstrap이나 rustdoc이 내부적으로 플래그를 전달해야 한다면, 같은 변경의 일부로 관련 명령 구성 코드에 플래그가 전달되도록 연결하십시오. -
플래그가 제어하는 동작에 초점을 맞춘 테스트를 추가하십시오. 예를 들어, UI 테스트에서는
//@ compile-flags: -Zyour-flag를 사용할 수 있으며, rustdoc 동작에 대한 테스트는 보통tests/rustdoc에 둡니다. -
플래그가 nightly rustc나 rustdoc 사용자를 위한 것이라면,
src/doc/unstable-book/src/compiler-flags아래의 unstable book에 문서화하십시오.
PR을 열기 전에 ./x test tidy와 새 플래그를 검증하는 좁은 범위의 테스트 스위트를 실행하십시오.
테스트 요청
구현이 완료되면 해당 기능은 nightly 사용자가 사용할 수 있게 되지만, 아직 stable Rust에 포함되지는 않습니다. 이때가 Rust 공식 블로그에 블로그 글을 작성하여 “테스트 요청“을 하기에 좋은 시점입니다.
이전에 작성된 이러한 블로그 글로는 다음과 같은 것들이 있습니다:
또는 This Week in Rust에도 이를 위한 섹션이 있습니다. 이 섹션이 사용된 예시는 다음과 같습니다:
어떤 방법을 선택할지는 언어 변경이 얼마나 중대한지에 따라 달라질 수 있지만, This Week in Rust 섹션은 Rust 공식 블로그의 전용 글보다 눈에 덜 띌 수 있다는 점에 유의하십시오.
다듬기
사용자에게 잘 다듬어진 경험을 제공한다는 것은 단순히 rustc에 기능을 구현하는 것 이상을 의미합니다. 우리가 배포하는 모든 도구와 자료를 고려해야 합니다. 이 작업에는 다음이 포함됩니다:
- Rust Reference에 언어 기능을 문서화하는 것.
- 새로운 구문을 포맷할 수 있도록
rustfmt를 확장하는 것(해당하는 경우). rust-analyzer를 확장하는 것(해당하는 경우). 일부 기능은 완전한 지원이 갖춰질 때까지 막아 둘 필요가 없으므로, 이 작업의 범위는 언어 기능의 성격에 따라 달라질 수 있습니다.rust-analyzer에서 지원이 구현되기 전에 언어 기능이 존재하는 것만으로 사용자 경험이 저하된다면, lang 팀이 이를 차단 우려 사항으로 제기할 수 있습니다.- 그러한 예로는
rust-analyzer가 파싱할 수 없는 새로운 구문이나, 이해하지 못하는 타입 추론 변경으로 인해 잘못된 진단이 발생하는 경우 등을 들 수 있습니다.
안정화
기능 생명 주기의 마지막 단계는 안정화이며, 이 단계에서 해당 기능을 모든 Rust 사용자가 사용할 수 있게 됩니다. 이 시점부터는 일반적으로 하위 호환성을 깨는 변경이 더 이상 허용되지 않습니다(자세한 내용은 lang 팀의 정의된 semver 정책을 참고하십시오). 안정화에 대해 더 알아보려면 안정화 가이드를 참고하십시오.
안정성 보장
이 페이지에서는 안정성 보장의 개요를 설명합니다.
RFC
블로그 글
rustc-dev-guide 링크
예외
저희 인프라 중 일부를 외부에서 사용할 수 있더라도, 이는 여전히 내부용으로 간주되며 안정성이 보장되지 않습니다. 다음은 안정성이 보장되지 않는 구성 요소의 목록이며, 전체 목록은 아닙니다:
remote-test-client/remote-test-server가 사용하는 CLI 및 환경 변수
안정성 속성
이 절에서는 rustc 표준 라이브러리에서 안정 API가 내부적으로 불안정 API를 사용할 수 있게 해 주는 안정성 속성과 체계를 설명합니다.
참고: 이 절은 언어 기능이 아니라 라이브러리 기능을 다룹니다. 언어 기능을 안정화하는 방법은 기능 안정화하기를 참고하십시오.
unstable
#[unstable(feature = "foo", issue = "1234", reason = "lorem ipsum")] 속성은 항목을 불안정한 것으로 명시적으로 표시합니다. “unstable“로 표시된 항목은 nightly 컴파일러에서도 크레이트에 해당하는 #![feature] 속성이 없으면 사용할 수 없습니다. 이 제한은 크레이트 경계를 넘을 때만 적용되므로, 불안정 항목을 정의한 크레이트 안에서는 해당 항목을 사용할 수 있습니다.
issue 필드는 관련된 GitHub 이슈 번호를 지정합니다. 이 필드는 필수이며, 모든 불안정 기능에는 관련된 추적 이슈가 있어야 합니다. 적절한 값이 없는 드문 경우에는 issue = "none"을 사용합니다.
unstable 속성은 모든 하위 항목에 전파되므로 하위 항목에 속성을 다시 적용할 필요가 없습니다. 따라서 이 속성을 모듈에 적용하면 모듈 안의 모든 항목이 불안정해집니다.
기능의 이름을 바꾸는 경우 old_name = "old_name"을 추가하면 유용한 오류 메시지를 표시할 수 있습니다.
특정 하위 항목에 #[stable] 속성을 사용하면 해당 항목을 안정 상태로 만들 수 있습니다. 안정성 체계는 pub가 동작하는 방식과 비슷하게 동작합니다. 공개되지 않은 모듈에 공개 함수가 있을 수 있듯이, 불안정 모듈에 안정 함수가 있을 수도 있고 그 반대도 가능합니다.
이전에는 rustc 버그 때문에 불안정 모듈 안의 안정 항목을 해당 위치에서 안정 코드가 사용할 수 있었습니다. 2024년 9월 기준으로, 실수로 안정화된 경로를 가진 항목에는 해당 경로에 의존하는 코드가 깨지지 않도록 #[rustc_allowed_through_unstable_modules] 속성이 지정되어 있습니다. 호환성을 깨는 변경을 피하는 데 필요한 경우가 아니라면 이 속성을 다른 항목에 추가하지 마십시오.
unstable 속성에는 soft 값을 지정할 수도 있으며, 이 값을 지정하면 하드 오류 대신 향후 호환되지 않는 기본 거부(deny-by-default) 린트가 됩니다. 이 값은 과거에 실수로 허용되었던 bench 속성에서 사용합니다. 이렇게 하면 Cargo의 린트 상한(lint capping)을 활용하여 의존성이 깨지는 것을 막을 수 있습니다.
stable
#[stable(feature = "foo", since = "1.420.69")] 속성은 항목을 안정화된 것으로 명시적으로 표시합니다. 안정 함수의 본문에서도 불안정한 요소를 사용할 수 있다는 점에 유의하십시오.
rustc_const_unstable
#[rustc_const_unstable(feature = "foo", issue = "1234", reason = "lorem ipsum")]는 unstable 속성과 동일한 인터페이스를 가집니다. 이 속성은 const fn의 const성(constness)이 불안정하다는 것을 표시하는 데 사용합니다. 이 속성은 다음과 같은 드문 경우에만 필요합니다:
const fn이 불안정 언어 기능이나 내장 함수(intrinsic)를 사용하는 경우입니다. (이런 경우에는 컴파일러가 속성을 추가하라고 알려 줍니다.)const fn이#[stable]이지만 아직 const-stable로 만들 의도가 없는 경우입니다.- const-unstable 내장 함수를 호출하는 데 필요한 기능 게이트를 변경하기 위한 것입니다.
const 안정성은 재귀적이라는 점에서 일반적인 안정성과 다릅니다. 즉, #[rustc_const_unstable(...)] 함수는 안정 코드에서 간접적으로도 호출할 수 없습니다. 이는 불안정한 컴파일러 구현 산출물이 실수로 안정 코드에 유출되거나, 불완전한 구현의 우발적인 특이점에 우리가 묶이게 되는 것을 방지하기 위함입니다. 이 검사를 세밀하게 조정하는 방법은 아래의 rustc_const_stable_indirect 및 rustc_allow_const_fn_unstable 속성을 참조하십시오.
rustc_const_stable
#[rustc_const_stable(feature = "foo", since = "1.420.69")] 속성은 const fn의 const성이 stable임을 명시적으로 표시합니다.
rustc_const_stable_indirect
#[rustc_const_stable_indirect] 속성을 #[rustc_const_unstable(...)] 함수에 추가하면 해당 함수를 #[rustc_const_stable(...)] 함수에서 호출할 수 있게 됩니다. 이는 해당 함수가 구현 측면에서 안정화될 준비가 되었음(즉, 불안정한 컴파일러 기능을 사용하지 않음)을 나타내며, 아직 const-stable이 아닌 유일한 이유는 API에 관한 우려 때문입니다.
이는 컴파일러에서 const 호출이 합성되는 언어 항목에도 추가해야 하며, 이를 통해 해당 호출이 재귀적 const 안정성 규칙을 우회하지 않도록 보장합니다.
rustc_intrinsic_const_stable_indirect
내장 함수에 이 속성을 지정하면 해당 내장 함수가 “공개 안정 함수에서 사용할 준비가 됨“으로 표시됩니다. 내장 함수에 rustc_const_unstable 속성이 있다면 제거해야 합니다. 내장 함수에 이 속성을 추가하려면 t-lang 및 wg-const-eval의 승인이 필요합니다!
rustc_default_body_unstable
#[rustc_default_body_unstable(feature = "foo", issue = "1234", reason = "lorem ipsum")] 속성은 unstable 속성과 동일한 인터페이스를 가집니다. 이 속성은 트레이트 내 항목의 기본 구현을 불안정으로 표시하는 데 사용됩니다. default-body-unstable 항목이 있는 트레이트는 그러한 항목마다 명시적인 본문을 제공하여 안정적으로 구현할 수 있으며, 또는 해당하는 #![feature]를 활성화하여 기본 본문을 사용할 수도 있습니다.
라이브러리 기능 안정화하기
기능을 안정화하려면 다음 단계를 따르십시오.
- @T-libs 멤버에게 추적 이슈에서 FCP를 시작해 달라고 요청하고, FCP가 (
disposition-merge로) 완료될 때까지 기다리십시오. #[unstable(...)]을#[stable(since = "CURRENT_RUSTC_VERSION")]로 변경하십시오.- 이 API에 대한 모든 테스트 또는 문서 테스트에서
#![feature(...)]를 제거하십시오. 해당 기능이 컴파일러나 도구에서 사용되고 있다면 그곳에서도 제거하십시오. const fn인 경우#[rustc_const_stable(since = "CURRENT_RUSTC_VERSION")]를 추가하십시오. 또는 아직 const 안정화할 대상이 아니라면, 새로운 기능 게이트(와 새 추적 이슈)에 대해#[rustc_const_unstable(...)]를 추가하십시오.rust-lang/rust를 대상으로 PR을 여십시오.- 적절한 레이블을 추가하십시오:
@rustbot modify labels: +T-libs. - 추적 이슈에 링크하고 “Closes #XXXXX“라고 적으십시오.
- 적절한 레이블을 추가하십시오:
기능 안정화의 예시는 FCP가 진행된 추적 이슈 #81656과 관련 구현 PR #84642에서 확인할 수 있습니다.
allow_internal_unstable
매크로와 컴파일러 디슈가링(desugaring)은 자신의 본문을 호출 지점에 노출합니다. 표준 라이브러리의 매크로에서 불안정한 기능을 사용할 수 없는 제약을 우회하기 위해, 지정한 기능을 안정 매크로에서 사용할 수 있게 해 주는 #[allow_internal_unstable(feature1, feature2)] 속성이 있습니다.
매크로가 const 컨텍스트에서 사용되어 #[rustc_const_unstable(...)] 함수에 대한 호출을 생성하는 경우, allow_internal_unstable이 있더라도 여전히 거부된다는 점에 유의하십시오. 매크로가 재귀적 const 안정성 검사를 실수로 우회하지 못하도록 해당 함수에 #[rustc_const_stable_indirect]를 추가하십시오.
rustc_allow_const_fn_unstable
위에서 설명한 것처럼, 안정 const fn 내부에서는 불안정한 const 기능을 간접적으로도 사용할 수 없습니다.
그러나 때로는 어떤 기능이 언제 안정화될지는 모르더라도 안정화될 것임은 확실히 알고 있거나, 안정적인(그러나 예를 들어 런타임에 느린) 우회 방법이 존재하여, 불안정 기능을 폐기하더라도 언제든 어떤 안정 버전으로 대체할 수 있는 경우가 있습니다. 그러한 경우, [rustc_allow_const_fn_unstable(feature1, feature2)] 속성을 사용하여 안정(또는 간접적으로 안정) const fn의 본문에서 일부 불안정 기능을 허용할 수 있습니다.
또한 런타임에 호출하든 컴파일 타임에 호출하든 동일하게 동작해야 한다는 const fn 불변 조건을 지키도록 주의해야 합니다(이 블로그 글도 참고하십시오). 이는 예를 들어 메모리 주소를 정수로 transmute하는 const fn을 만들어서는 안 된다는 의미입니다. 대상의 주소는 비결정적이며 컴파일 타임에는 알 수 없는 경우가 많기 때문입니다.
어떤 const fn에든 rustc_allow_const_fn_unstable 속성을 추가하는 경우에는 항상 @rust-lang/wg-const-eval에 알리십시오.
staged_api
stable 또는 unstable 속성을 사용하는 모든 크레이트는 크레이트에 #![feature(staged_api)] 속성을 포함해야 합니다.
deprecated
표준 라이브러리의 사용 중단(deprecation)은 사용자 코드의 사용 중단과 거의 동일합니다. 항목에 #[deprecated]가 사용되는 경우, 해당 항목에는 stable 또는 unstable 속성도 있어야 합니다.
deprecated는 다음과 같은 형식을 가집니다.
#[deprecated(
since = "1.38.0",
note = "explanation for deprecation",
suggestion = "other_function"
)]
suggestion 필드는 선택 사항입니다. 지정하는 경우, 경고를 수정하기 위해 기계적으로 적용할 수 있는 제안으로 사용할 수 있는 문자열이어야 합니다. 이는 일반적으로 식별자의 이름이 바뀌었지만 그 외에 중요한 변경이 필요하지 않은 경우에 사용됩니다. suggestion 필드를 사용하는 경우, 크레이트 루트에 #![feature(deprecated_suggestion)]가 있어야 합니다.
사용자 코드와의 또 다른 차이점은 since 필드가 실제로 현재 rustc 버전과 비교하여 검사된다는 것입니다. since가 미래 버전인 경우 deprecated_in_future 린트가 발생하는데, 이 린트의 기본값은 allow이지만 표준 라이브러리의 대부분은 #![warn(deprecated_in_future)]로 이를 경고로 격상합니다.
unstable_feature_bound
#[unstable_feature_bound(foo)] 속성을 #[unstable] 속성과 함께 사용하면 안정 타입과 안정 트레이트의 impl을 불안정으로 표시할 수 있습니다. std/core에서 #[unstable_feature_bound(foo)]가 지정된 항목은 마찬가지로 #[unstable_feature_bound(foo)]가 지정된 다른 항목에서만 사용할 수 있습니다. std/core 외부에서 #[unstable_feature_bound(foo)]가 있는 항목을 사용하려면 크레이트에 #![feature(foo)] 속성으로 해당 기능을 활성화해야 합니다.
현재 #[unstable_feature_bound]를 지정할 수 있는 항목은 다음과 같습니다.
impl- 자유 함수
- 트레이트
이름이 바뀌거나 제거된 기능
불안정 기능은 이름이 바뀌거나 제거될 수 있습니다. 기능의 이름을 바꾸는 경우 #[unstable] 속성에 old_name = "old_name"을 추가할 수 있습니다. 기능을 제거하는 경우, 제거된 기능을 사용하는 사용자에게 적절한 오류 메시지를 표시하려면 #![unstable_removed(feature = "foo", reason = "brief description", link = "link", since = "1.90.0")] 속성을 사용해야 합니다.
link 필드를 사용하면 GitHub 이슈, 댓글, PR처럼 기능 제거와 가장 관련 깊은 정보로 연결되는 링크를 걸 수 있습니다.
안정화 요청
참고: 이 페이지는 언어 기능의 안정화를 다룹니다. 라이브러리 기능의 안정화에 대해서는 라이브러리 기능 안정화하기를 참조하십시오.
불안정 기능이 충분히 테스트되었고 남은 우려 사항이 없다면 누구든지 그 기능의 안정화를 추진할 수 있습니다. 다만 해당 기능을 작업해 온 사람들을 참여시키는 것이 바람직합니다. 다음 단계를 따르십시오:
필요한 경우 RFC 작성하기
해당 기능이 언어 실험의 일부였다면, lang 팀은 일반적으로 안정화에 앞서 RFC를 먼저 승인하기를 원합니다.
문서 PR
해당 기능은 src/doc/unstable-book에 있는 Unstable Book에 문서화되어 있을 수 있습니다. 기능 게이트에 대한 페이지가 있다면 제거하십시오. 그 문서에서 유용한 부분은 다른 곳에 통합하십시오.
문서 업데이트가 필요할 수 있는 곳은 다음과 같습니다:
- The Reference: 반드시 빠짐없이 상세하게 업데이트해야 하며, 안정화가 병합되기 전에 lang-docs 팀의 구성원이 해당 PR을 리뷰하고 승인해야 합니다.
- The Book: 필요에 따라 업데이트합니다. 확실하지 않다면 이 저장소에 이슈를 열어 주십시오. 그곳에서 논의할 수 있습니다.
- 표준 라이브러리 문서: 필요에 따라 업데이트합니다. 언어 기능은 대개 이것이 필요하지 않지만,
?가 언어에 추가되었을 때처럼 관용적인 예제의 작성 방식을 바꾸는 기능이라면 라이브러리 문서에서 이러한 예제를 업데이트하는 것이 중요합니다. 표준 라이브러리의 키워드 문서와 ABI 문서도 검토하십시오. 이 문서들은 언어 변경에 따라 업데이트가 필요한 경우가 있습니다. - Rust by Example: 필요에 따라 업데이트합니다.
위에서 언급한 저장소들에 대해 이 새 기능과 관련된 문서를 업데이트하는 PR을 준비하십시오. 이 저장소들의 메인테이너는 전체 안정화 과정이 완료될 때까지 이 PR들을 열어 둡니다. 그동안 다음 단계로 진행할 수 있습니다.
안정화 보고서 작성하기
이 저장소에 있는 템플릿을 사용하여 안정화 보고서를 작성하십시오.
안정화 보고서는 다음 내용을 요약합니다:
- RFC가 승인된 이후의 주요 설계 결정과 변경 사항입니다. 여기에는 FCP를 거쳤거나 그 밖의 방식으로 언어 팀이 승인한 결정과 lang 팀에 처음으로 제시되는 결정이 모두 포함됩니다.
- 최종적으로 안정화되는 언어 기능은 원래 RFC와 설계상 크게 달라지는 경우가 많습니다. 그래도 괜찮지만, 이러한 차이점은 반드시 강조하고 신중하게 설명해야 합니다.
- RFC가 승인된 이후 수행된 작업입니다. 언어 기능을 진전시키는 데 기여한 주요 기여자들을 함께 밝힙니다.
안정화 템플릿에는 이 기능과 lang의 하위 팀(예: types, opsem, lang-docs 등) 사이의 연관성을 드러내고 흔히 간과되는 항목을 찾아내기 위한 일련의 질문이 포함되어 있습니다.
안정화 보고서는 일반적으로 안정화 PR의 주 댓글로 게시됩니다(다음 절을 참조하십시오).
안정화 PR
기능마다 사정이 다르며, 일부 기능에는 이 가이드에서 다루는 것 이상의 단계가 필요할 수 있습니다.
lang 팀이 안정화를 검토하려면 먼저 해당 기능을 설명하는 완전한 PR이 Reference에 제출되어 있어야 하며, 안정화 PR이 병합되기 전에 이 PR이 lang-docs 팀의 리뷰와 승인을 받아야 합니다.
기능 게이트 목록 업데이트하기
불안정 기능 게이트의 중앙 목록은 compiler/rustc_feature/src/unstable.rs에 있습니다. declare_features! 매크로를 검색하십시오. 안정화하려는 기능에 대한 항목이 다음과 비슷한 형태로 있을 것입니다(rust-lang/rust#32409에서 가져온 예시입니다).
// pub(restricted) visibilities (RFC 1422)
(unstable, pub_restricted, "CURRENT_RUSTC_VERSION", Some(32409)),
위 줄은 compiler/rustc_feature/src/accepted.rs로 옮겨야 합니다. declare_features! 호출 안의 항목들은 정렬되어 있으므로 올바른 위치를 찾으십시오. 작업을 마치면 다음과 같은 모습이어야 합니다.
// pub(restricted) visibilities (RFC 1422)
(accepted, pub_restricted, "CURRENT_RUSTC_VERSION", Some(32409)),
// note that we changed this
(파일에서 과거 변경 사항의 버전 번호를 보게 되더라도, 안정화가 이루어질 것으로 예상하는 rustc 버전을 적지 말고 대신 CURRENT_RUSTC_VERSION을 사용하십시오.)
기능 게이트의 기존 사용처 제거하기
다음으로, 코드베이스에서 기능 문자열(이 경우 pub_restricted)을 검색하여 어디에 나타나는지 찾으십시오. std와 모든 rustc 크레이트(library/와 compiler/ 아래의 테스트 폴더는 포함하지만 최상위 tests/ 폴더는 포함하지 않음)에서 #![feature(XXX)] 사용을 #![cfg_attr(bootstrap, feature(XXX))]로 변경하십시오. 이렇게 하면 현재 베타로 빌드되는 stage1에서만 기능 게이트가 포함됩니다(현재 베타에서는 해당 기능이 아직 불안정하므로 이 작업이 필요합니다).
또한 모든 테스트(예: tests/ 아래)에서 해당 문자열을 제거하십시오. 기능 게이트만을 대상으로 하는 테스트(즉, 기능을 사용하려면 기능 게이트가 필요하다는 것만 테스트하고 그 외에는 아무것도 테스트하지 않는 테스트)가 있다면 그 테스트를 그냥 제거하십시오.
기능을 사용할 때 기능 게이트를 요구하지 않게 하기
가장 중요한 것은 기능 게이트가 없을 때 오류 플래그를 띄우는 코드를 제거하는 것입니다(이제 해당 기능은 안정된 것으로 간주되기 때문입니다). 해당 기능이 새로운 구문을 사용하여 감지될 수 있다면, 그 코드는 흔히 compiler/rustc_ast_passes/src/feature_gate.rs에 있습니다. 예를 들어 다음과 같은 코드를 볼 수 있습니다.
gate_all!(pub_restricted, "`pub(restricted)` syntax is experimental");
gate_all! 매크로는 pub_restricted 기능이 활성화되지 않은 경우 오류를 보고합니다. 이제 pub(restricted)가 안정화되었으므로 이 코드는 더 이상 필요하지 않습니다.
더 미묘한 기능의 경우 다음과 같은 코드를 발견할 수 있습니다.
if self.tcx.features().async_fn_in_dyn_trait() { /* XXX */ }
이 pub_restricted 필드(기능 이름을 딴 것)는 보통 기능 플래그가 없으면 false이고, 있으면 true입니다. 따라서 이 필드가 true라고 가정하도록 코드를 변환하십시오. 이 경우에는 if를 제거하고 /* XXX */만 남기면 됩니다.
if self.tcx.sess.features.borrow().pub_restricted { /* XXX */ }
becomes
/* XXX */
if self.tcx.sess.features.borrow().pub_restricted && something { /* XXX */ }
becomes
if something { /* XXX */ }
팀 지명
안정화 PR을 열 때 lang 팀과 그 자문단(@rust-lang/lang @rust-lang/lang-advisors), 그리고 해당 기능과 관련된 다른 모든 팀을 CC하십시오. 예를 들면 다음과 같습니다.
@rust-lang/types: 타입 시스템과의 상호 작용 관련@rust-lang/opsem: unsafe 코드와의 상호 작용 관련@rust-lang/compiler: 구현의 견고성 관련@rust-lang/libs: 표준 라이브러리 API나 그 보장 사항을 변경하는 경우입니다.@rust-lang/lang-docs: 이 기능을 Reference에 어떻게 문서화해야 하는지에 관한 질문이 있는 경우입니다.
안정화 보고서와 함께 안정화 PR을 연 뒤에는 곧바로 달리는 의견이 있는지 잠시 기다리십시오. 그런 의견이 “잦아들고” PR이 lang 팀의 검토를 받을 준비가 되었다고 판단되면, 다가오는 lang 회의에서 검토할 안건에 오르도록 PR을 지명하십시오.
rust-lang 조직의 구성원이 아니라면, 배정된 리뷰어에게 관련 팀을 대신 CC해 달라고 요청할 수 있습니다.
PR에 FCP 제안하기
lang 팀과 기타 관련 팀이 안정화를 검토하고 여러분이 그들의 질문에 모두 답변하고 나면, 해당 팀 중 한 팀의 구성원이 다음과 같이 댓글을 달아 안정화 수락을 제안할 수 있습니다.
@rfcbot fcp merge
충분한 수의 팀 구성원이 검토를 마치면 PR은 “최종 의견 수렴 기간”(FCP)에 들어갑니다. 새로운 우려 사항이 제기되지 않으면 이 기간이 끝나고, 통상적인 방식으로 구현 리뷰를 거친 뒤 PR을 병합할 수 있습니다.
안정화 리뷰 및 병합
안정화 PR에 r+를 주기 전에 PR이 다음 조건을 충족하는지 확인하십시오.
- 팀이 안정화를 위해 제안한 내용 및 Reference PR에 문서화된 내용과 일치합니다.
- 우려 사항을 해결하거나 피하기 위해 팀이 진행 과정에서 요청하기로 결정한 모든 변경 사항을 포함합니다.
- 그 밖의 부분은 안정화 보고서와 관련 RFC 또는 이전 lang FCP에 기술된 내용과 정확히 일치합니다.
- 명시되고, 안정화가 수락되고, Reference에 문서화된 동작 외에는 어떠한 동작도 stable에 노출하지 않습니다.
- 이러한 사항을 설득력 있게 입증할 만큼 충분한 테스트를 갖추고 있습니다.
- lang-docs 구성원이 리뷰하고 승인한 Reference PR이 함께 제출되어 있습니다.
특히 PR을 리뷰할 때는 lang 팀이 미처 고려하거나 명시하지 못한, 사용자에게 드러나는 세부 사항이 있는지 주의 깊게 살피십시오. 그런 사항을 발견하면 이를 설명하고 lang 팀에 PR을 지명하십시오.
안정화 보고서 템플릿
이것은 무엇입니까?
이것은 언어 기능의 안정화 보고서를 위한 템플릿입니다. 이 질문들은 가장 자주 필요한 세부 사항을 이끌어 내는 것을 목표로 합니다. 이러한 세부 사항은 리뷰어가 잠재적인 문제를 미리 파악하는 데 도움이 됩니다. 템플릿의 모든 부분이 모든 안정화에 해당하지는 않습니다. 질문이 해당하지 않는 경우, 그 이유를 간략히 설명하십시오.
구분선 이후의 모든 내용을 복사하여 Markdown으로 편집하십시오. 각 TODO를 여러분의 답변으로 바꾸십시오.
안정화 보고서
요약
이 기능이 무엇이며 어떤 가치를 제공하는지 다시 한번 설명해 주십시오. 이 안정화에 이르기까지의 경위를 이야기해 주십시오.
예를 들어, 다음을 참고하십시오.
TODO
추적:
- TODO (추적 이슈 링크)
Reference PR:
- TODO (Reference PR 링크)
cc @rust-lang/lang @rust-lang/lang-advisors
안정화되는 것
안정화되는 각 동작을 설명하고, 이제 허용되는 코드의 짧은 예시를 제시하십시오.
#![allow(unused)]
fn main() {
todo!()
}
안정화되지 않는 것
기능 중 안정화되지 않는 부분을 설명하십시오. 나중에 하고자 할 수 있는 것과 이를 위해 열어 두는 여지에 대해 이야기하십시오. 안정화하지 않는 부분이 사용자에게 예상치 못한 결과를 초래할 수 있다면, 특히 그 점에 대해 이야기하십시오.
설계
Reference
Reference에는 어떤 업데이트가 필요합니까? 각 PR에 대한 링크를 제공하십시오. 이 기능을 설명하는 데 필요한 내용이 Reference에 빠져 있다면 그 점을 논의하십시오.
- TODO
RFC 이력
이 기능에 대해 어떤 RFC가 승인되었습니까?
- TODO
미해결 질문에 대한 답변
RFC에서 해결되지 않고 남은 질문은 무엇입니까? 그 질문들에는 어떻게 답이 내려졌습니까? 관련된 lang 결정이 있다면 링크를 제공하십시오.
TODO
RFC 이후의 변경 사항
RFC가 승인된 이후 사용자에게 드러나는 다른 변경 사항으로는 무엇이 있었습니까? lang 팀이 승인한 변경 사항(해당 결정에 대한 링크 포함)과 이 안정화 보고서에서 팀에 처음 제시되는 변경 사항을 모두 설명하십시오.
TODO
핵심 논점
어떤 결정이 가장 어려웠으며, 안정화할 동작 중 어떤 것이 가장 논쟁적이었습니까? 모든 측의 주요 인자를 요약하고 이전 문서와 논의에 링크하십시오.
TODO
Nightly 확장
이 기능의 확장 중 여전히 불안정한 상태로 남아 있는 것이 있습니까? 그러한 확장을 실수로 확정하지 않는다는 것을 어떻게 알 수 있습니까?
TODO
닫히는 문
이 안정화는 향후 언어 변경에 대해 어떤 문을 닫습니까? 예를 들어, 이 안정화로 인해 다른 RFC, 언어 실험 또는 진행 중인 것으로 알려진 제안을 나중에 수행하기가 더 어려워지거나 불가능해집니까?
피드백
테스트 요청
“테스트 요청(call for testing)“이 이루어졌습니까? 그렇다면 어떤 피드백을 받았습니까?
TODO
nightly 사용
이 기능을 사용하는 것으로 알려진 nightly 사용자가 있습니까? GitHub에서 grep으로
#![feature(FEATURE_NAME)]의 사용 사례 수를 세어 보면 유용한 정보를 얻을 수 있습니다.
TODO
구현
주요 부분
구현의 주요 부분을 요약하고, 코드와 관련 PR로 연결되는 링크를 제공하십시오.
예를 들어, async 클로저의 주요 부분을 다음과 같이 세분화한 내용을 참고하십시오:
TODO
커버리지
이 기능의 테스트 커버리지를 요약하십시오.
이 기능의 “경계“가 무엇인지 고려하십시오. 저희는 특히 이 기능과 인접한 것 중 정확히 무엇을 안정화하지 않는지 확인해 주는 테스트를 보고 싶습니다. 물론 테스트는 기능이 동작한다는 것을 포괄적으로 입증해야 합니다. 흔한 실수를 저질러 기능을 잘못 사용했을 때 나타나는 진단 메시지를 보여 주는 것도 고려하십시오.
각 테스트의 맨 위에 테스트의 목적과 입증하고자 하는 불변 조건들을 설명하는 주석을 포함하십시오. 이는 저희의 리뷰에 큰 도움이 됩니다.
테스트 커버리지에서 알려져 있거나 의도적으로 비워 둔 부분을 설명하십시오.
테스트 폴더와 개별 테스트에 대한 맥락을 설명하고 링크를 제공하십시오.
TODO
해결되지 않은 버그
이 기능과 관련된 해결되지 않은 버그에는 어떤 것이 있습니까? 해당 버그를 나열하십시오. 이 중 안정화를 블록해야 하는 것이 있습니까? 그렇게 판단한 이유를 논의하십시오.
TODO
- TODO
- TODO
- TODO
해결되지 않은 FIXME
해당 기능의 코드에 아직 남아 있는 FIXME는 무엇이며, 이를 그대로 두어도 괜찮은 이유는 무엇입니까?
TODO
도구 변경 사항
이 기능을 지원하기 위해 다른 도구에 어떤 변경이 필요합니까? 이 작업은 완료되었습니까? 관련된 모든 PR과 이슈의 링크를 제공하십시오.
- rustfmt
- TODO
- rust-analyzer
- TODO
- rustdoc(JSON 및 HTML 모두)
- TODO
- cargo
- TODO
- clippy
- TODO
- rustup
- TODO
- docs.rs
- TODO
TODO
호환성을 깨는 변경
이 안정화가 알려진 호환성을 깨는 변경에 해당한다면, crater 보고서, crater 보고서 분석, 그리고 이 호환성 깨짐의 영향을 받는 생태계 프로젝트에 우리가 제출한 모든 PR에 대한 링크를 제공하십시오. 우리가 파악하거나 수정할 수 있는 범위에 어떤 한계가 있는지 논의하십시오.
TODO
Crater 보고서:
- TODO
Crater 분석:
- TODO
영향을 받는 크레이트에 제출한 PR:
- TODO
- TODO
- TODO
타입 시스템, 연산 의미론
컴파일 타임 검사
정의되지 않은 동작을 방지하기 위해 필요한 컴파일 타임 검사로는 어떤 것들이 수행됩니까?
이러한 검사가 수행되고 있음을 보여 주는 테스트의 링크를 제시하십시오.
TODO
- TODO
- TODO
- TODO
타입 시스템 규칙
이 기능에 대해 어떤 타입 시스템 규칙이 강제되며, 각 규칙의 목적은 무엇입니까?
TODO
기본적으로 건전합니까?
이 기능의 구현에 UB를 방지하기 위한 특정한 검사가 필요합니까, 아니면 기본적으로 건전하며 위험한/unsafe 연산을 수행하려면 명시적인 옵트인이 필요합니까? 기본적으로 건전하지 않다면, 그 근거는 무엇입니까?
TODO
추상 기계(AM)를 깨뜨립니까?
사용자가 이 기능을 사용하여 정의되지 않은 동작을 일으키거나, Rust의 추상화를 깨뜨리고 그 아래에 있는 어셈블리 수준의 구현을 드러낼 수 있습니까? 그렇다면 이를 설명하십시오.
TODO
일반적인 상호 작용
임시 값
이 기능은 임시 값을 생성할 수 있는 새로운 표현식을 도입합니까? 그러한 임시 값의 스코프는 무엇입니까?
TODO
드롭 순서
이 기능은 값을 드롭해야 하는 순서에 관한 의문을 제기합니까? 여기서 내려진 결정과, 그 결정이 이전의 결정들과 어떻게 일관되는지 설명하십시오.
TODO
확장 전 / 확장 후
이 기능은 확장 전에 허용되어야 하는 것(예:
#[cfg(false)]로 덮인 코드)과 확장 후에 허용되어야 하는 것에 관한 의문을 제기합니까? 이에 관해 어떤 결정이 내려졌습니까?
TODO
에디션 위생
이 기능이 에디션에 따라 제한된다면, 토큰의 에디션 위생 측면에서 코드를 허용할지 거부할지를 어떻게 결정합니까? 예를 들어, 결정할 때 어떤 토큰을 기준으로 삼습니까?
TODO
SemVer에 미치는 영향
이 기능으로 인해 라이브러리 작성자가 마이너 버전을 릴리스할 때 다운스트림이 깨지지 않도록 새롭게 주의해야 하는 부분이 생깁니까? 그러한 부분을 설명하십시오. 이러한 새로운 위험 요소는 RFC 1105에 따르면 “메이저“입니까, “마이너“입니까?
TODO
다른 기능의 노출
이 기능으로 인해 어떤 식으로든 동작이 노출될 수 있는 다른 불안정 기능이 있습니까? 그럴 위험이 가장 큰 기능은 무엇입니까?
TODO
경과
지금에 이르기까지의 과정을 이해하는 데 중요한 이슈와 PR을 나열하십시오.
- TODO
- TODO
- TODO
감사의 말
공로를 인정하고 안정화 소식을 알릴 수 있도록 이 기능에 기여한 사람들을 이름과 함께 정리하십시오. 이 작업에 참여한 사람 중 지금 안정화해야 한다고 생각하지 않는 사람이 있습니까? 그렇다면 그 의견을 듣고 싶습니다.
TODO
미결 항목
안정화 전에 완료되어야 하지만 아직 완료되지 않은 것으로 알려진 항목을 모두 나열하십시오.
- TODO
- TODO
- TODO
기능 게이트
이 장은 기능 게이트를 추가, 제거, 수정하는 데 필요한 기본적인 도움을 제공하기 위한 것입니다.
rustc가 컴파일러 파이프라인에서 기능 게이트를 어떻게 강제하고 검사하는지는 기능 게이트 검사를 참조하십시오.
이 내용은 언어 기능 게이트에만 해당한다는 점에 유의하십시오. 라이브러리 기능 게이트는 다른 메커니즘을 사용합니다.
기능 게이트 추가하기
방법은 “새 기능 구현하기” 절의 “코드에서의 안정성”을 참조하십시오.
기능 게이트 제거하기
기능 게이트를 제거하려면 다음 단계를 따르십시오.
-
rustc_feature/src/unstable.rs에서 기능 게이트 선언을 제거하십시오. 선언은 다음과 같은 형태입니다./// description of feature (unstable, $feature_name, "$version", Some($tracking_issue_number)) -
방금 제거한 기능 게이트 선언을 수정한 버전을
rustc_feature/src/removed.rs에 추가하십시오./// description of feature (removed, $old_feature_name, "$version", Some($tracking_issue_number), Some("$why_it_was_removed"))
기능 게이트 이름 바꾸기
기능 게이트의 이름을 바꾸려면 다음 단계를 따르십시오(처음 두 단계는 기능 게이트를 제거할 때 따르는 단계와 같습니다).
-
rustc_feature/src/unstable.rs에서 이전 기능 게이트 선언을 제거하십시오. 선언은 다음과 같은 형태입니다./// description of feature (unstable, $old_feature_name, "$version", Some($tracking_issue_number)) -
방금 제거한 이전 기능 게이트 선언을 수정한 버전을
rustc_feature/src/removed.rs에 추가하십시오./// description of feature /// Renamed to `$new_feature_name` (removed, $old_feature_name, "$version", Some($tracking_issue_number), Some("renamed to `$new_feature_name`")) -
새 이름으로 된 기능 게이트 선언을
rustc_feature/src/unstable.rs에 추가하십시오. 이전 선언과 매우 비슷한 형태여야 합니다./// description of feature (unstable, $new_feature_name, "$version", Some($tracking_issue_number))
기능 안정화하기
방법은 “기능 안정화하기” 장의 “기능 게이트 목록 갱신하기”를 참조하십시오. 선언을 갱신하는 것 외에도 추가로 거쳐야 할 단계가 있습니다!
둘러보기: 새로운 언어 기능, 설계부터 안정화까지
이 장에서는 새로운 언어 기능의 설계와 구현 과정을 차례대로 살펴봅니다. 이 단계 중 일부는 언어 기능에만 해당하지만, 상당수는 라이브러리 기능에도 적용됩니다.
개요
이 장에서 다룰 기능은 매크로용 ? 클리니(Kleene) 연산자입니다. 요컨대, 다음과 같은 코드를 작성할 수 있기를 원합니다:
macro_rules! foo {
($arg:ident $(, $optional_arg:ident)?) => {
println!("{}", $arg);
$(
println!("{}", $optional_arg);
)?
}
}
fn main() {
let x = 0;
foo!(x); // ok! prints "0"
foo!(x, x); // ok! prints "0 0"
}
요컨대 매크로의 $(pat)? 매처는 다른 정규식 구문과 마찬가지로 “이 패턴은 0번 또는 1번 나타날 수 있음“을 의미합니다.
아이디어를 안정된 Rust 기능으로 만들기까지는 여러 단계를 거쳤습니다. 간단한 목록은 다음과 같습니다. 아래에서 각 단계를 순서대로 살펴보겠습니다. 앞서 언급했듯이, 모든 유형의 기여에 이 단계가 전부 필요한 것은 아닙니다.
- 아이디어 논의/Pre-RFC Pre-RFC는 기능에 대한 초기 초안 또는 설계 논의입니다. 이 단계는 설계 공간을 어느 정도 구체화하고 아이디어의 여러 장점과 문제점을 파악하기 위한 것입니다. 더 많은 사람에게 아이디어를 공개하기 전에 초기 피드백을 받을 수 있는 좋은 방법입니다. 원래 논의는 여기에서 볼 수 있습니다.
- RFC 이 단계에서는 검토를 받기 위해 아이디어를 커뮤니티에 공식적으로 제안합니다. RFC는 여기에서 볼 수 있습니다.
- 구현 아이디어를 컴파일러에 불안정 기능으로 구현하십시오. 원래 구현은 여기에서 볼 수 있습니다.
- 반복/개선(필요한 경우) 커뮤니티가 nightly 컴파일러와
std에서 기능을 사용해 보면서, 설계 선택에 관해 조정이 필요할 수 있는 추가 피드백이 나올 수 있습니다. 이 기능은 여러 차례의 반복을 거쳤습니다. - 안정화 기능이 충분히 무르익으면 Rust 팀 구성원이 안정화를 제안할 수 있습니다. 합의가 이루어지면 안정화가 완료됩니다.
- 휴식 이제 여러분의 기능은 안정된 Rust 기능입니다!
Pre-RFC와 RFC
참고: 일반적으로 Rust나 생태계에 새로운 기능이나 중대한 변경을 제안하는 것이 아니라면 RFC 절차를 따를 필요가 없습니다. 대신 곧바로 구현으로 넘어가면 됩니다.
RFC를 언제 열어야 하는지에 대한 공식 지침은 여기에서 볼 수 있습니다.
RFC는 제안하려는 기능이나 변경 사항을 자세히 설명하는 문서입니다. RFC는 누구나 작성할 수 있으며, 절차는 Rust 팀 구성원을 포함한 모든 사람에게 동일합니다.
RFC를 열려면 GitHub의 rust-lang/rfcs 저장소에 PR을 여십시오. 자세한 안내는 README에서 볼 수 있습니다.
RFC를 열기 전에 아이디어를 “구체화“하기 위한 조사를 해야 합니다. 성급하게 제안된 RFC는 수락되지 않는 경향이 있습니다. 일반적으로 동기, 영향, 단점, 그리고 다른 기능과의 잠재적 상호작용을 잘 설명할 수 있어야 합니다.
할 일이 많아 보인다면, 실제로 그렇기 때문입니다. 하지만 걱정하지 마십시오! 컴파일러 해커가 아니더라도 pre-RFC를 진행하면 훌륭한 피드백을 받을 수 있습니다. 이는 아이디어에 대한 비공식적인 논의입니다. 이를 진행하기에 가장 좋은 곳은 internals.rust-lang.org입니다. 게시물이 특정한 구조를 따를 필요는 없습니다. 심지어 일관된 아이디어일 필요도 없습니다. 일반적으로 수많은 피드백을 받게 되며, 이를 다시 반영하여 좋은 RFC를 작성할 수 있습니다.
(또 하나의 유용한 팁: RFC 저장소와 internals에서 이전에 제기된 관련 아이디어를 검색해 보십시오. 어떤 아이디어가 이미 검토되었다가 거부되었거나, 나중에 다시 시도하기로 연기된 경우가 많습니다. 이렇게 하면 여러분과 다른 모든 사람의 시간을 절약할 수 있습니다)
이 예시의 경우, pre-RFC 스레드의 한 참여자가 구문 모호성과 가능한 해석 방안을 지적했습니다. 또한 전반적인 피드백은 긍정적인 것으로 보였습니다. 이 경우에는 논의가 꽤 빠르게 수렴했지만, 어떤 아이디어는 훨씬 더 많은 논의가 이루어질 수 있습니다(예: 무려 684개의 댓글을 받은 이 RFC를 참고하십시오!). 그런 일이 생기더라도 낙담하지 마십시오. 이는 커뮤니티가 여러분의 아이디어에 관심이 있지만, 아마도 약간의 조정이 필요하다는 뜻입니다.
우리의 ? 매크로 기능에 대한 RFC도 RFC 스레드에서 어느 정도 논의가 이루어졌습니다. 대부분의 RFC와 마찬가지로 논의만으로는 답할 수 없는 몇 가지 질문이 있었습니다. 결정을 내리려면 기능을 실제로 사용해 본 경험이 필요했습니다. 이러한 질문은 RFC의 “Unresolved Questions”(미해결 질문) 섹션에 나열됩니다. 또한 RFC 논의가 진행되는 동안 논의의 흐름을 반영하도록 RFC 문서 자체를 갱신하고 싶어질 것입니다(예: 새로운 대안이나 선행 연구가 추가될 수도 있고, 제안 자체의 일부를 변경하기로 결정할 수도 있습니다).
결국 논의가 합의에 이르고 다소 잦아든 것으로 보이면, Rust 팀 구성원이 세 가지 가능한 처리 방침 중 하나와 함께 “최종 의견 수렴 기간”(FCP)으로 넘어갈 것을 제안할 수 있습니다. 이는 해당 팀의 다른 구성원들이 RFC를 검토하고 의견을 남기기를 원한다는 뜻입니다. 추가 논의가 이어질 수 있으며, 그 결과 더 많은 변경 사항이나 미해결 질문이 추가될 수 있습니다. 어느 시점에 모두가 만족하면 RFC는 FCP에 들어가며, 이는 사람들이 이의를 제기할 수 있는 마지막 기회입니다. FCP가 끝나면 해당 처리 방침이 채택됩니다. 가능한 세 가지 처리 방침은 다음과 같습니다:
- 병합(Merge): 기능을 수락합니다. 다음은 우리의
?매크로 기능에 대한 병합 제안입니다. - 종료(Close): 현재 형태의 이 기능은 Rust에 적합하지 않습니다. 여러분의 RFC가 이렇게 처리되더라도 낙담하지 마시고, 개인적으로 받아들이지 마십시오. 이는 여러분에 대한 평가가 아니라, Rust가 다른 방향으로 나아가겠다는 커뮤니티의 결정입니다.
- 연기(Postpone): 이 방향으로 나아가는 데 관심은 있지만 지금은 아닙니다. 이는 대부분 해당 Rust 팀이 안정화에 이르는 과정 동안 기능을 이끌어 갈 여력이 없기 때문에 발생합니다. 기능이 팀의 로드맵에 맞지 않을 때 이런 경우가 많습니다. 연기된 아이디어는 나중에 다시 검토될 수 있습니다.
RFC가 병합되면 해당 PR이 RFCs 저장소에 병합됩니다. 기능의 진행 상황을 추적하고 미해결 질문, 구현 진행 상황, 걸림돌 등을 논의하기 위해 rust-lang/rust 저장소에 새로운 추적 이슈가 생성됩니다. 다음은 ? 매크로 기능에 대한 추적 이슈입니다.
실험적 RFC(eRFC)
eRFC는 상위 수준의 필요성은 분명하지만 설계 공간이 너무 넓어 상세한 명세를 미리 확정할 수 없는 복잡한 기능에 사용되는 RFC 프로세스의 변형입니다. eRFC는 최종 설계를 제시하는 대신, 적극적인 실험 기간을 승인하기 위한 상위 수준의 전략을 개괄합니다. 이를 통해 팀은 기능 게이트 뒤에서 기능을 구현하고 실제 데이터를 수집할 수 있으며, 이 데이터는 이후 안정화를 위한 정식 RFC의 근거가 됩니다. 이 프로세스는 코루틴과 같은 주요 기능에 사용되었지만(RFC 2033 참조), 오늘날에는 명시적인 “eRFC” 라벨이 거의 사용되지 않습니다. 현재 프로젝트는 일반적으로 초기 버전에 대해 표준 RFC를 승인한 뒤, 최종 안정화 전까지 nightly 채널을 통해 반복적으로 개선하는 방식을 선호합니다.
구현
컴파일러를 변경하려면 rust-lang/rust 저장소에 PR을 여십시오.
기능, 변경, 버그 수정, 개선 사항에 따라 구현이 비교적 간단할 수도 있고 큰 작업이 될 수도 있습니다. 더 경험이 많은 컴파일러 개발자에게 언제든지 도움이나 멘토링을 요청할 수 있습니다. 또한 기능을 반드시 직접 구현할 필요는 없습니다. 다만 직접 구현하지 않으면 다른 사람이 구현하기까지 시간이 꽤 걸릴 수 있다는 점을 염두에 두십시오.
? 매크로 기능의 경우, 저는 컴파일러에서 매크로 확장과 관련된 부분을 이해해야 했습니다. 개인적으로는 코드의 주석을 개선하는 것이 코드를 제대로 이해했는지 확인하는 데 도움이 된다고 생각하지만, 원하지 않는다면 그렇게 하지 않아도 됩니다.
그런 다음 RFC에 설명된 대로 원래 기능을 구현했습니다. 새로운 기능이 구현되면 기능 게이트 뒤에 놓이며, 이는 해당 기능을 사용하려면 #![feature(my_feature_name)]를 사용해야 한다는 뜻입니다. 기능이 안정화되면 기능 게이트가 제거됩니다.
대부분의 버그 수정과 개선 사항에는 기능 게이트가 필요하지 않습니다. 변경이나 개선 사항을 그대로 적용하시면 됩니다.
PR을 열고 병합하는 방법에 대한 정보는 PR 생명주기를 참조하십시오.
새로운 기능 같은 것을 작업하는 것이 아니라면(예: 버그를 수정하는 경우) 이것으로 끝입니다! 기여해 주셔서 감사합니다 :)
구현 다듬기
사람들이 nightly에서 새 기능을 사용해 보며 경험을 쌓으면서 사소한 변경이 제안되거나 미해결 질문이 해결될 수 있습니다. 업데이트나 변경은 위에서 설명한 것처럼 다른 변경 사항을 구현할 때와 같은 절차를 거칩니다(즉, PR을 제출하고, 리뷰를 거치고, @bors를 기다리는 등).
어떤 변경은 FCP와 Rust 팀 구성원의 검토가 필요할 만큼 중대할 수 있습니다.
? 매크로 기능의 경우, 최초 구현 이후 몇 차례의 반복 작업을 거쳤습니다: 1, 2, 3.
그 과정에서 ?가 구분자를 받지 않도록 결정했으며, 이는 이전에 RFC에 미해결 질문으로 나열되어 있던 사항입니다. 또한 모호성 해소 전략도 변경했습니다. 다른 반복 연산자(예: + 또는 *)에서 ?를 구분자 토큰으로 사용하는 기능을 제거하기로 결정했습니다. 하지만 이는 호환성을 깨는 변경이었기 때문에 에디션 경계를 넘어서 적용하기로 결정했습니다. 따라서 새 기능은 2018 에디션에서만 활성화할 수 있습니다. 원래 RFC와의 이러한 차이점 때문에 또 한 번의 FCP가 필요했습니다.
안정화
마지막으로, 이 기능이 nightly에서 한동안 무르익은 뒤 언어 팀 구성원 한 명이 안정화를 발의했습니다.
다음 내용을 포함하는 안정화 보고서를 작성해야 합니다.
- 동작에 대한 간략한 설명과 RFC와의 차이점
- 영향을 받는 에디션과 그 방식
- 흥미로운 측면을 보여 주는 몇 가지 테스트에 대한 링크
이 기능의 안정화 보고서는 여기에 있습니다.
그 후 기능 게이트를 제거하여 (2018 에디션에서) 기능을 기본적으로 활성화하는 PR이 만들어집니다. 릴리스 노트에 이 기능에 대한 설명이 추가됩니다.
기능을 안정화하는 단계는 기능 안정화하기에서 확인할 수 있습니다.
코딩 규칙
이 장에서는 포맷팅, 정확성을 위한 코딩, crates.io의 크레이트 사용을 다루고, 리뷰하기 쉽도록 PR을 구성하는 방법에 대한 몇 가지 팁을 소개합니다.
포맷팅과 tidy 스크립트
rustc는 Rust 표준 코딩 스타일을 따르는 방향으로 나아가고 있습니다.
하지만 현재로서는 안정 버전의 rustfmt를 사용하지 않고 특별한 설정을 적용한 고정 버전을 사용하므로, 일반적인 rustfmt와는 다른 스타일이 나올 수 있습니다. 따라서 cargo fmt로 이 저장소를 포맷팅하는 것은 권장하지 않습니다.
대신 포맷팅은 ./x fmt로 해야 합니다. 커밋하기 전마다 ./x fmt를 실행하는 습관을 들이는 것이 좋습니다. 이렇게 하면 나중에 충돌이 줄어듭니다.
포맷팅은 tidy 스크립트가 검사합니다. 이 스크립트는 ./x test를 실행할 때 자동으로 실행되며, ./x fmt --check로 따로 실행할 수도 있습니다.
참고: 포맷팅과 테스트 스위트
tests/디렉터리 아래의 Rust 소스 파일 대부분은 공백 민감성, 스냅숏 테스트의 특성, 위치에 민감한 주석 등의 이유로 포맷팅되지 않습니다.어떤 테스트 파일이 포맷팅되지 않는지는 https://github.com/rust-lang/rust/blob/main/rustfmt.toml의
ignore항목을 참고하십시오.
편집기에서 저장 시 포맷팅 기능을 사용하려면, build/<target>/stage0/bin/rustfmt에 빌드되는 고정 버전의 rustfmt를 사용하십시오.
C++ 코드 포맷팅
컴파일러에는 안정적인 C API가 없는 LLVM 일부와 연동하기 위한 C++ 코드가 일부 포함되어 있습니다. 해당 코드를 수정할 때는 다음 명령으로 포맷팅하십시오:
./x test tidy --extra-checks cpp:fmt --bless
이 명령은 로컬 환경에 의존하지 않도록 고정 버전의 clang-format을 사용합니다.
Python 코드 포맷팅과 린트
Rust 저장소에는 상당히 많은 Python 코드가 포함되어 있습니다. 저희는 ruff 도구로 이 코드의 린트와 포맷팅을 모두 관리하려고 노력합니다.
Python 코드를 수정할 때는 다음 명령으로 포맷팅하십시오:
./x test tidy --extra-checks py:fmt --bless
그리고 린트를 실행하려면 다음 명령을 사용하십시오:
./x test tidy --extra-checks py:lint
이 명령들은 로컬 환경에 의존하지 않도록 고정 버전의 ruff를 사용합니다.
저작권 고지
예전에는 파일 첫머리에 저작권 및 라이선스 고지가 있었습니다. 표준 조건(MIT OR Apache-2.0)으로 라이선스되는 새 파일에서는 이 고지를 생략하십시오.
지금쯤이면 저작권 고지가 모두 제거되었어야 하지만, rust-lang/rust 저장소에서 남아 있는 고지를 발견하면 언제든 이를 제거하는 PR을 여십시오.
줄 길이
한 줄은 최대 100자여야 합니다. 80자 이내로 유지할 수 있다면 더욱 좋습니다.
때로는, 특히 테스트의 경우, 이 제한에서 예외를 두어야 할 때가 있습니다. 그런 경우에는 다음과 같이 파일 상단 부근에 주석을 추가할 수 있습니다:
#![allow(unused)]
fn main() {
// ignore-tidy-linelength
}
탭 대 공백
4칸 들여쓰기를 사용하십시오.
정확성을 위한 코딩
서식 외에도 따를 만한 몇 가지 다른 팁이 있습니다.
모든 경우를 다루는 매치를 사용하십시오
매치에서 _를 사용하면 편리하지만, 열거형에 새로운 배리언트가 추가될 때 해당 배리언트가 올바르게 처리되지 않을 수 있습니다. 스스로에게 물어보십시오. 이 열거형에 새로운 배리언트가 추가된다면, 그 배리언트가 다른 방식으로 처리되어야 하는 것이 아니라 _ 코드를 사용해야 할 가능성은 얼마나 됩니까? 답이 “낮음“이 아니라면 모든 경우를 다루는 매치를 사용하십시오.
if let과 while let에도 같은 조언이 적용됩니다. 이 둘은 사실상 단일 배리언트에 대한 테스트이기 때문입니다.
잊고 싶지 않은 것에는 “TODO” 주석을 사용하십시오
PR을 병합하기 전에 다시 살펴보고 싶은 부분이 있다면, 스스로를 위한 유용한 도구로서 // TODO 주석을 삽입할 수 있습니다:
fn do_something() {
if something_else {
unimplemented!(); // TODO write this
}
}
tidy 스크립트는 // TODO 주석에 대해 오류를 보고하므로, 이 코드는 TODO가 수정(또는 제거)될 때까지 병합될 수 없습니다.
이는 PR 안에서 어떤 커밋이 이후 커밋에서 수정할 버그를 남겨 두고 있음을 알리는 방법으로도 유용할 수 있습니다:
if foo {
return true; // TODO wrong, but will be fixed in a later commit
}
코드베이스에 메모를 남기고 싶다면 대신 // FIXME를 사용하십시오.
주변 코드의 스타일을 따르십시오
기존 헬퍼를 사용하고 로직이나 검증을 중복하지 마십시오.
진실 공급원을 중복하지 마십시오
서로 다른 두 곳의 데이터를 동기화된 상태로 유지하려는 것은 코드 스멜입니다.
타입을 사용하여 불변 조건을 강제하십시오
가능하다면 생성 시점에 검사하는 데 그치지 말고 잘못된 상태를 아예 표현할 수 없게 만드십시오.
유용한 주석을 작성하십시오
무엇을 했는지가 아니라 왜 그렇게 했는지를 설명하는 주석을 작성하십시오. 명확하지 않은 버그에 대해서는 자세히 설명해도 괜찮습니다.
기존 동작을 유지하십시오
플랫폼 간 차이와 오류 상황을 고려하십시오. 엣지 케이스를 검증하는 관련 테스트를 찾아보십시오.
작은 단계로 나누어 작업하십시오
작고 독립적으로 테스트할 수 있는 단계로 나누어 작업하십시오. 의미 있는 변경을 할 때마다 관련 테스트를 실행하십시오. 그래야 처음 잘못된 지점이 어디인지 알 수 있습니다.
crates.io의 크레이트 사용하기
crates.io 의존성 절을 참고하십시오.
PR 구성 방법
PR의 커밋을 어떻게 준비하느냐에 따라 리뷰어의 부담이 크게 달라질 수 있습니다. 몇 가지 팁을 소개합니다.
“순수 리팩터링“은 별도의 커밋으로 분리하십시오. 예를 들어 메서드 이름을 바꾸는 경우, 그 이름 변경을 모든 사용처의 이름 변경과 함께 별도의 커밋에 넣으십시오.
일반적으로 커밋은 많을수록 좋습니다. 큰 변경을 하는 경우, 이를 독립적으로 이해할 수 있는 더 작은 단계로 나누는 것이 거의 항상 더 좋습니다. 한 가지 주의할 점은, 어떤 전략에 따라 코드를 도입한 다음 이후 커밋에서 그 코드를 (덧붙이는 것이 아니라) 크게 바꾸면 그러한 ’오락가락’이 혼란을 줄 수 있다는 것입니다.
포맷팅을 자주 하십시오. PR의 마지막 커밋만 올바르게 포맷팅되어 있으면 되지만, ./x fmt를 사용하여 각 커밋을 개별적으로 포맷팅하면 리뷰하기도 쉽고 불필요한 변경도 줄어듭니다.
병합하지 마십시오. bors가 만든 것 외에는 히스토리에 병합 커밋을 허용하지 않습니다. 병합 충돌이 발생하면 병합 대신 git rebase --interactive rust-lang/main과 같은 명령으로 리베이스하십시오(원격 저장소 이름으로 rust-lang을 사용한다고 가정합니다).
개별 커밋이 빌드될 필요는 없습니다(하지만 빌드되면 좋습니다). 모든 중간 커밋이 성공적으로 빌드되어야 한다고 요구하지는 않으며, PR 단위로 bisect할 수 있기만 하면 됩니다. 하지만 개별 커밋이 빌드되도록 할 수 있다면 언제나 도움이 됩니다.
명명 규칙
일반적인 Rust 스타일 및 명명 규칙 외에도, 컴파일러에만 해당하는 규칙이 몇 가지 있습니다.
-
cx는 대개 “context“의 약어이며, 접미사로 자주 사용됩니다. 예를 들어tcx는 타이핑 컨텍스트(Typing Context)를 가리키는 일반적인 이름입니다. -
'tcx는 타이핑 컨텍스트의 라이프타임 이름으로 사용됩니다. -
crate는 키워드이므로, 크레이트와 관련된 무언가를 나타내는 변수가 필요하면 철자를krate로 바꾸는 경우가 많습니다.
호환성을 깨는 변경을 위한 절차
이 페이지는 기존 코드가 더 이상 컴파일되지 않게 만들 수 있는 컴파일러의 버그 수정이나 건전성 수정을 수행할 때 따라야 할 모범 사례 절차를 정의합니다. 이 글은 RFC 1589를 기반으로 합니다.
동기
때때로 기존 코드가 더 이상 컴파일되지 않게 만드는 버그 수정, 건전성 수정 또는 그 밖의 변경을 컴파일러에 적용해야 할 때가 있습니다. 이런 경우에는 Rust 사용자가 순조롭게 전환할 수 있는 방식으로 변경을 처리하는 것이 중요합니다. 우리가 피하고 싶은 것은 기존 프로그램이 알아보기 힘든 오류 메시지와 함께 갑자기 컴파일되지 않는 상황입니다. 그 대신 문제가 무엇인지, 어떻게 고쳐야 하는지, 왜 변경이 이루어졌는지에 대한 명확한 안내와 함께 점진적인 경고 기간을 두는 편을 선호합니다. 이 RFC는 그러한 순조로운 전환을 이루는 것을 목표로, 호환성을 깨는 변경을 처리하기 위해 우리가 발전시켜 온 절차를 설명합니다.
이 정책의 핵심 사항 중 하나는 (a) 가능하다면 처음에는 하드 오류가 아닌 경고를 내야 하며, (b) 기존 코드가 더 이상 컴파일되지 않게 만드는 모든 변경에는 관련된 추적 이슈가 있어야 한다는 점입니다. 이 이슈는 해당 변경의 결과에 대한 피드백을 모으는 창구 역할을 합니다. 때로는 변경이 예상보다 큰 영향을 미치거나, 미처 고려하지 못한 변경 회피 방법이 있을 수도 있습니다. 그런 경우 방향을 바꾸어 변경을 되돌리거나 다른 해결책을 찾기로 결정할 수 있습니다(경고를 사용하고 있다면 이렇게 하기가 특히 쉽습니다).
무엇이 버그 수정에 해당합니까?
이 RFC는 호환성을 깨는 변경이 언제 허용되는지를 정의하려 하지 않는다는 점에 유의하십시오. 그 내용은 이미 RFC 1122에서 다루고 있습니다. 이 문서는 이루어지는 변경이 해당 정책을 따른다고 가정합니다. 다음은 RFC 1122에 나온 조건을 요약한 것입니다:
- 건전성 변경: 타입 시스템에서 발견된 허점을 수정하는 것입니다.
- 컴파일러 버그: 컴파일러가 RFC나 언어 팀 결정에 명시된 의미론을 구현하지 않고 있는 부분입니다.
- 명세가 불충분한 언어 의미론: 컴파일러가 일관되지 않게 동작하고 이전에 공식적인 동작이 정해진 적 없는 회색 지대를 명확히 하는 것입니다.
자세한 내용은 해당 RFC를 참고하십시오!
상세 설계
호환성을 깨는 변경을 하는 절차는 다음과 같습니다(각 단계는 아래에서 더 자세히 설명합니다):
- 변경의 영향을 평가하기 위해 crater 실행을 수행하십시오.
- 해당 변경 전용의 특별 추적 이슈를 만드십시오.
- 곧바로 오류를 보고하지 마십시오. 그 대신 상위 호환성 린트 경고를 내십시오.
- 때로는 이것이 간단하지 않습니다. 과거에 우리가 사용했던 여러 기법에 대한 제안은 아래 글을 참고하십시오.
- 경고를 내는 것이 불가능한 경우:
- 오류를 보고하되, 사용자를 추적 이슈로 안내하는 구체적인 오류 메시지를 제공하도록 최선을 다하십시오
- 영향을 받는 것으로 알려진 모든 크레이트에 문제를 수정하는 PR을 제출하십시오
- 또는 최소한 해당 크레이트의 소유자에게 문제를 알리고 추적 이슈를 안내해야 합니다.
- 변경 사항이 실제 환경에서 최소 한 주기 이상 사용된 후에는 해당 경고를 오류로 전환하여 변경 사항을 안정화할 수 있습니다.
마지막으로, 플러그인에 영향을 미치는 rustc_ast 변경 사항은 일괄적으로 묶어서 처리하는 것이 일반적인 정책입니다. 이에 대해서는 아래에서 더 자세히 설명합니다.
추적 이슈
모든 호환성을 깨는 변경에는 해당 변경을 위한 전용 추적 이슈가 함께 있어야 합니다. 이 이슈의 본문에는 적용되는 변경 사항을 설명하되, 사용자가 코드를 고치기 위해 무엇을 해야 하는지에 중점을 두어야 합니다. 이슈는 이해하기 쉽고 실용적이어야 합니다. 자세한 내용은 사용자에게 RFC나 다른 이슈를 안내하는 것이 좋을 수도 있습니다. 이 이슈는 사용자가 질문이나 기타 우려 사항을 댓글로 남길 수 있는 공간의 역할도 합니다.
호환성을 깨는 변경의 추적 이슈 템플릿은 여기에서 찾을 수 있습니다. 이러한 이슈가 어떤 모습이어야 하는지에 대한 예시는 여기에서 찾을 수 있습니다.
향후 호환성 경고 발행하기
호환성을 깨는 변경을 처리하는 가장 좋은 방법은 먼저 향후 호환성 경고를 발행하는 것입니다. 이는 린트 경고의 특수한 범주입니다. 새로운 향후 호환성 경고는 다음과 같이 추가할 수 있습니다.
#![allow(unused)]
fn main() {
// 1. Define the lint in `compiler/rustc_lint/src/builtin.rs` and
// add the metadata for the future incompatibility:
declare_lint! {
pub YOUR_LINT_HERE,
Warn,
"illegal use of foo bar baz"
@future_incompatible = FutureIncompatibleInfo {
reason: fcw!(FutureReleaseError #1234) // your tracking issue here!
},
}
// 2. Add a dedicated lint pass for it.
// This step can be skipped if you emit the lint as part of an existing pass.
#[derive(Default)]
pub struct MyLintPass {
...
}
impl {Early,Late}LintPass for MyLintPass {
...
}
impl_lint_pass!(MyLintPass => [YOUR_LINT_HERE]);
// 3. emit the lint somewhere in your lint pass:
cx.emit_span_lint(
YOUR_LINT_HERE,
pat.span,
// some diagnostic struct
MyDiagnostic {
...
},
);
}
마지막으로, compiler/rustc_lint/src/lib.rs에 린트를 등록하십시오. 해당 파일에는 등록 방법을 보여 주는 예시가 이미 많이 있습니다.
유용한 기법
새로운 경고를 기존의 오래된 오류와 구분해 내기는 어려운 경우가 많습니다. 과거에 사용된 기법 중 하나는 기존 코드를 변경 없이 실행하여 그 코드가 보고했을 오류를 수집하는 것입니다. 그런 다음, 새로 내게 될 오류 중 원래 집합에 나타나지 않는 오류에 대해 경고를 발행할 수 있습니다. 또 다른 방법은 원래 코드가 실행을 마친 뒤 오류가 보고되었다면 컴파일을 중단하는 것입니다. 이렇게 하면 새 코드는 이전에 오류가 없었던 경우에만 실행된다는 것을 알 수 있습니다.
Crater와 crates.io
Crater는 변경 사항이 적용된 컴파일러로 모든 crates.io 크레이트와 여러 공개 GitHub 저장소를 컴파일하는 봇입니다. 그런 다음, 변경 사항으로 인해 더 이상 컴파일되지 않거나 새로 컴파일되기 시작한 크레이트를 담은 보고서가 생성됩니다. Crater 실행은 완료되기까지 며칠이 걸릴 수 있습니다.
영향을 평가하기 위해 항상 crater 실행을 해야 합니다. 호환성을 깨는 변경의 영향을 받는 크레이트 작성자에게 알리는 것이 예의 바르고 사려 깊은 일입니다. 문제를 고치는 PR을 제출하면 더욱 좋습니다.
곧바로 오류를 발행해도 괜찮은 경우가 있습니까?
영향이 미미하다고 판단되는 변경은 곧바로 오류를 발생시키는 단계로 넘어갈 수 있습니다. 한 가지 경험칙은 crates.io를 기준으로 확인하는 것입니다. 영향을 받는 프로젝트가 전체 10개 미만으로 발견되면(루트 오류 기준이 아님) 곧바로 오류로 전환할 수 있습니다. 이러한 경우에도 이전과 마찬가지로 “호환성을 깨는 변경” 페이지를 만들어야 하며, 오류가 사용자를 이 페이지로 안내하도록 해야 합니다. 다시 말해, 사용자가 경고 대신 오류를 받는다는 점을 제외하면 모든 것이 동일해야 합니다. 또한 영향을 받는 프로젝트에 PR을 제출해야 합니다(이상적으로는 변경을 구현하는 PR이 rustc에 반영되기 전에 제출해야 합니다).
영향이 미미하지 않다고 판단되는 경우(예: 10개가 넘는 크레이트가 영향을 받는 경우)에는 경고가 필요합니다(특정 사례에 대해 컴파일러 팀이 특별 예외를 허용하기로 합의한 경우는 제외합니다). 경고를 구현하기가 어렵다면, 영향을 받는 크레이트의 수를 줄이기 위해 변경을 반영하기 전에 크레이트를 마이그레이션하는 적극적인 전략을 세워야 합니다. 이러한 상황에 대처하는 몇 가지 기법은 다음과 같습니다.
- 문제의 하위 부분에 대해서는 경고를 발생시키고, 새로운 오류는 가능한 한 가장 작은 범위의 사례에만 적용하십시오.
- 문제를 해결하는 방법을 제안하고 사용자를 추적 이슈로 안내하는 매우 정확한 오류 메시지를 제공하도록 노력하십시오.
- 수정을 단계적으로 나누어 적용하는 것도 합리적일 수 있습니다.
- 먼저 가능한 곳에 경고를 추가하고, 그것이 반영된 후에 오류를 발생시키는 단계로 진행하십시오.
- 하위 사용자들이 사용할 수 있도록, 수정이 반영되기 전에 수정된 버전이 제공되도록 영향을 받는 크레이트의 작성자들과 협력하십시오.
안정화
변경이 이루어진 후에는 불안정 기능에 사용하는 것과 동일한 절차를 사용하여 변경을 안정화합니다.
-
새 릴리스가 나온 후에는 호환성을 깨는 변경에 해당하는 미해결 추적 이슈들을 검토하고, 그중 일부를 최종 의견 수렴 기간(FCP)에 올립니다.
-
이러한 이슈에 대한 FCP는 한 사이클 동안 진행됩니다. 사이클의 마지막 한두 주 동안 의견을 검토하고 다음과 같이 최종 결정을 내립니다.
- 오류로 전환: 변경을 하드 오류로 만들어야 합니다.
- 되돌리기: 경고를 제거하고 이전 코드가 계속 컴파일되도록 허용해야 합니다.
- 보류: 아직 결정할 수 없으므로 더 기다리거나 다른 전략을 시도합니다.
이상적으로는, 호환성을 깨는 변경이 확정되기 전에 컴파일러의 안정 브랜치에 반영되어 있어야 합니다.
린트 제거하기
“향후 경고“를 하드 오류로 만들기로 결정했다면, 해당 사용자 정의 린트를 제거하는 PR이 필요합니다. 예를 들어, overlapping_inherent_impls 호환성 린트를 제거하는 데 필요한 단계는 다음과 같습니다. 먼저 린트 이름을 대문자로 변환한 다음(OVERLAPPING_INHERENT_IMPLS), 소스에서 해당 문자열을 검색하십시오. 기본적으로 이 린트 이름이 언급된 각 위치를 변환하는 방식으로 진행합니다(컴파일러에서는 대문자 이름을 사용하고 매크로가 소문자 문자열을 자동으로 생성하므로, overlapping_inherent_impls로 검색하면 별로 찾을 수 없습니다).
참고: 이 파일들이 지금은 정확히 이대로 존재하지 않지만, 절차는 여전히 동일합니다.
린트를 제거하십시오.
가장 먼저 찾게 될 참조는 아마도 rustc_session/src/lint/builtin.rs에 있는 다음과 비슷한 린트 정의일 것입니다.
#![allow(unused)]
fn main() {
declare_lint! {
pub OVERLAPPING_INHERENT_IMPLS,
Deny, // this may also say Warning
"two overlapping inherent impls define an item with the same name were erroneously allowed",
@future_incompatible = FutureIncompatibleInfo {
reason: fcw!(FutureReleaseError #1234), // your tracking issue here!
},
}
}
이 declare_lint! 매크로는 관련 자료 구조를 생성합니다. 이 매크로를 제거하십시오. 또한 파일 뒷부분에 lint_array!의 일부로 OVERLAPPING_INHERENT_IMPLS가 언급된 곳이 있습니다. 이것도 제거하십시오.
이 린트를 제거된 린트 목록에 추가하십시오.
compiler/rustc_lint/src/lib.rs에는 “이름이 바뀌었거나 제거된 린트” 목록이 있습니다. 이 린트를 다음과 같이 목록에 추가할 수 있습니다.
#![allow(unused)]
fn main() {
store.register_removed("overlapping_inherent_impls", "converted into hard error, see #36889");
}
여기서 #36889는 해당 린트의 추적 이슈입니다.
린트를 발생시키는 곳 수정하기
마지막으로 보게 될 참조 유형은 실제로 린트 자체를 발생시키는 곳(즉, 경고가 나타나게 하는 곳)입니다. 이러한 곳은 삭제하지 않습니다. 대신 이러한 곳을 오류로 바꿔야 합니다. 이 경우 add_lint 호출은 다음과 같습니다.
#![allow(unused)]
fn main() {
self.tcx.sess.add_lint(lint::builtin::OVERLAPPING_INHERENT_IMPLS,
node_id,
self.tcx.span_of_impl(item1).unwrap(),
msg);
}
이런 용도로 node_span_lint가 쓰이는 경우도 자주 볼 수 있습니다.
이것을 오류로 바꾸어야 합니다. 어떤 경우에는 이 상황에 해당하는 오류가 이미 있을 수 있습니다. 그렇지 않은 경우에는 새 진단 코드를 할당해야 합니다. 새 진단 코드를 할당하는 방법은 여기에서 확인할 수 있습니다. 확장 설명에 이 부분에서 컴파일러 동작이 바뀌었다는 점을 언급하고, 해당 변경의 추적 이슈에 대한 참조를 포함하는 것이 좋습니다.
E0592를 코드로 채택했다고 가정해 봅시다. 그러면 위의 add_lint() 호출을 다음과 같이 바꿀 수 있습니다.
#![allow(unused)]
fn main() {
struct_span_code_err!(self.dcx(), self.tcx.span_of_impl(item1).unwrap(), E0592, msg)
.emit();
}
또는 다음과 같은 구조화된 진단을 사용하는 편이 더 좋습니다.
#![allow(unused)]
fn main() {
#[derive(Diagnostic)]
struct MyDiagnostic {
#[label]
span: Span,
...
}
}
테스트 수정하기
마지막으로 테스트 스위트를 실행하십시오. 이전에 overlapping_inherent_impls 린트를 참조하던 테스트가 몇 개 있을 것이며, 이러한 테스트는 수정해야 합니다. 일반적으로 테스트에 #[deny(overlapping_inherent_impls)]가 있었다면 그냥 제거하면 됩니다.
./x test
모두 끝났습니다!
PR을 여십시오.
외부 저장소 사용하기
rust-lang/rust git 저장소는 rust-lang 조직에 속한 다른 여러 저장소에 의존합니다. 의존성을 사용하는 주요 방식은 세 가지입니다.
- crates.io를 통한 Cargo 의존성으로 사용(예:
rustc-rayon) - git(예:
clippy) 또는 josh(예:miri) 서브트리로 사용 - git 서브모듈로 사용(예:
cargo)
일반적인 원칙은 다음과 같습니다.
- 생태계의 다른 사람들에게도 유용할 수 있는 라이브러리에는 crates.io를 사용하십시오.
- 컴파일러 내부에 의존하고, 호환성을 깨는 변경이 있을 때 함께 업데이트해야 하는 도구에는 서브트리를 사용하십시오.
- 컴파일러와 독립적인 도구에는 서브모듈을 사용하십시오.
외부 의존성(서브트리)
다음 외부 프로젝트는 어떤 형태로든 subtree를 사용하여 관리됩니다.
- clippy
- miri
- portable-simd
- rustfmt
- rust-analyzer
- rustc_codegen_cranelift
- rustc_codegen_gcc
- rustc-dev-guide
- compiler-builtins(컴파일러 내장 함수)
- stdarch
submodule 의존성(아래에서 설명합니다)과 달리, subtree 의존성은 트리 안에서 직접 업데이트할 수 있는 일반 파일과 디렉터리일 뿐입니다. 하지만 가능하다면 이러한 도구에 한정된 개선 사항, 버그 수정 등은 각 도구의 업스트림 저장소에 직접 제출해야 합니다. 예외적으로, 새로운 도구 기능이나 테스트를 구현하는 데 rustc 변경이 필요한 경우에는 하나의 통합된 rustc PR에서 진행해야 합니다.
마찬가지로, rustc 변경으로 인해 어떤 서브트리 의존성의 빌드가 깨진다면 그에 대한 수정 사항도 해당 rustc PR에 포함해야 합니다.
subtree 의존성은 현재 서로 다른 두 가지 방식으로 관리됩니다.
git subtree사용- josh 도구 사용하기
mirirust-analyzerrustc-dev-guidecompiler-builtinsstdarch
Josh 서브트리
josh 도구는 git 서브트리의 대안으로, git 히스토리를 다른 방식으로 관리하며 큰 저장소에서도 확장성이 더 좋습니다. josh로 작업하려면 전용 도구가 필요합니다. 동기화를 돕기 위해 rustc-josh-sync라는 보조 도구를 제공하며, 이 도구는 아래에서 설명합니다.
Josh 서브트리 동기화하기
Josh 서브트리 업데이트에는 rustc-josh-sync라는 전용 도구를 사용합니다. 아래 명령은 모든 Josh 서브트리에 사용할 수 있지만, miri는 풀을 할 때 몇 가지 추가 단계를 수행해야 한다는 점에 유의하십시오.
다음 명령으로 이 도구를 설치할 수 있습니다:
cargo install --locked --git https://github.com/rust-lang/josh-sync
풀(rust-lang/rust의 변경 사항을 서브트리로 동기화)과 푸시(서브트리의 변경 사항을 rust-lang/rust로 동기화)는 모두 서브트리 저장소에서 수행합니다(따라서 먼저 터미널에서 해당 저장소의 체크아웃 디렉터리로 이동하십시오).
풀 수행하기
- 서브트리에 PR을 생성하는 데 사용할 새 브랜치를 체크아웃하십시오
- 풀 명령을 실행하십시오
rustc-josh-sync pull - 브랜치를 자신의 포크에 푸시하고 서브트리 저장소에 PR을 생성하십시오
ghCLI가 설치되어 있다면rustc-josh-sync가 PR을 대신 생성해 줄 수 있습니다.
푸시 수행하기
참고: 진행하기 전에 josh-sync README에 있는 Git 관련 안내를 살펴보십시오.
- 푸시 명령을 실행하여
<gh-username>계정의rustc포크에<branch-name>이라는 이름의 브랜치를 생성하십시오rustc-josh-sync push <branch-name> <gh-username> <branch-name>에서rust-lang/rust로 PR을 생성하십시오
새 Josh 서브트리 의존성 만들기
저장소 의존성을 git subtree 또는 git submodule에서 josh로 옮기려면 이 가이드를 참고하십시오.
git 서브트리 동기화하기
서브트리 기반 의존성에 적용된 변경 사항은 이 저장소와 업스트림 도구 저장소 사이에서 주기적으로 동기화해야 합니다.
서브트리 동기화는 일반적으로 각 도구의 메인테이너가 담당합니다. 다른 사용자도 동기화 PR을 제출할 수 있지만, 그렇게 하려면 로컬 git 설치를 수정하고 매우 정확한 일련의 지침을 따라야 합니다. 이 지침은 몇 가지 유용한 팁 및 요령과 함께 Clippy 기여 가이드의 서브트리 변경 사항 동기화 섹션에 문서화되어 있습니다. 이 지침은 서브트리 기반의 모든 도구에 적용할 수 있으니, 해당 도구에 맞는 서브트리 디렉터리와 원격 저장소를 올바르게 사용하는지만 확인하십시오.
동기화 과정은 subtree push와 subtree pull의 두 방향으로 진행됩니다.
subtree push는 이 저장소의 사본에서 일어난 모든 변경 사항을 가져와, 로컬 변경 사항과 일치하는 커밋을 원격 저장소에 생성합니다. 서브트리를 건드린 모든 로컬 커밋은 원격 저장소에 커밋을 하나씩 생성하지만, 파일을 지정된 디렉터리에서 도구 저장소의 루트로 옮기도록 수정됩니다.
subtree pull은 마지막 subtree pull 이후 도구 저장소에서 발생한 모든 변경 사항을 가져와, 도구의 변경 사항을 Rust 저장소의 지정된 디렉터리로 옮기는 병합 커밋과 함께 이 커밋들을 rustc 저장소에 추가합니다.
항상 push를 먼저 수행하고 이를 도구의 기본 브랜치에 병합시키는 것을 권장합니다. 그러면 pull을 수행할 때 충돌 없이 병합됩니다. 풀 도중에 충돌을 해결하는 것도 분명히 가능하지만, PR이 충분히 빨리 병합되지 않아 새로운 충돌이 생기면 충돌 해석을 다시 해야 할 수도 있습니다. git subtree pull의 결과를 리베이스하려고 하지 마십시오. 병합 커밋을 리베이스하는 것은 일반적으로 좋지 않은 생각입니다.
항상 서브트리 디렉터리를 가리키는 -P 접두사와 그에 대응하는 원격 저장소를 지정해야 합니다. 디렉터리나 저장소를 잘못 지정하면 엉뚱한 디렉터리를 엉뚱한 원격 저장소에 push하려는 아주 골치 아픈 병합이 발생합니다. 다행히도 rustc에서 pull한 커밋이나 원격에 push한 브랜치 중 하나를 버리기만 하면 아무런 문제 없이 이를 중단하고 다시 시도할 수 있습니다. 이런 일이 일어나면 갑자기 동기화하려는 커밋이 수천 개나 생기기 때문에 대개 꽤 분명하게 알 수 있습니다.
새 서브트리 의존성 생성하기
기존 저장소로부터 새 서브트리 의존성을 생성하려면 (이 저장소의 루트 디렉터리에서!) 다음을 호출하십시오.
git subtree add -P src/tools/clippy https://github.com/rust-lang/rust-clippy.git master
이렇게 하면 새 커밋이 생성되며, 이 커밋은 어떤 상황에서도 리베이스해서는 안 됩니다! 리베이스해야 한다면 커밋을 삭제하고 작업을 다시 수행하십시오.
이제 완료되었습니다. src/tools/clippy 디렉터리는 마치 Clippy가 rustc 모노레포의 일부인 것처럼 동작하므로, 여러분(또는 서브트리를 동기화하는 다른 사람들) 외에는 실제로 git subtree를 사용할 필요가 없습니다.
외부 의존성(서브모듈)
Rust를 빌드할 때는 git 서브모듈로 추적되는 외부 git 저장소도 사용합니다. 전체 목록은 .gitmodules 파일에서 찾을 수 있습니다. 이 프로젝트 중 일부는 필수이며(표준 라이브러리를 위한 stdarch 등), 일부는 선택 사항입니다(src/doc/book 등).
서브모듈 사용법은 Git 사용하기 장에서 더 자세히 다룹니다.
일부 서브모듈은 빌드되지 않거나 테스트가 통과하지 않는 “깨진” 상태에 있는 것이 허용됩니다. 예를 들어 The Rust Reference 같은 문서 책이 그렇습니다. 이러한 프로젝트의 메인테이너는 프로젝트가 깨진 상태일 때 알림을 받으며, 가능한 한 빨리 이를 수정해야 합니다. 현재 상태는 toolstate 웹사이트에서 추적됩니다. 자세한 내용은 Forge의 Toolstate 장에서 찾을 수 있습니다. 실제로 문서의 toolstate가 깨지는 경우는 매우 드뭅니다.
베타 채널과 안정 채널에서는 깨지는 것이 허용되지 않으며, PR이 병합되기 전에 반드시 해결해야 합니다. 또한 베타 분기 직전 한 주 동안에는 main에서도 깨지는 것이 허용되지 않습니다.
퍼징
이 가이드에서 퍼징은 rustc의 버그를 찾아내기 위해 매우 다양한 프로그램을 컴파일하는 모든 테스트 방법론을 가리킵니다. 퍼징은 주로 내부 컴파일러 오류(ICE)를 찾는 데 사용됩니다. 퍼징은 사용자가 버그를 겪기 전에 먼저 찾아낼 수 있으므로 유익합니다. 또한 작고 독립적인 프로그램을 제공하므로 버그를 추적하기가 더 쉬워집니다. 그러나 흔히 저지르는 몇 가지 실수는 퍼징의 유용성을 떨어뜨리고 결국 기여자들을 더 힘들게 만들 수 있습니다. Rust 프로젝트에 최대한 긍정적인 영향을 주려면 퍼저가 생성한 버그를 보고하기 전에 이 가이드를 읽어 주십시오!
지침
요약
해야 할 일:
- 최신 nightly rustc에서도 버그가 여전히 발생하는지 확인하십시오
- 모든 버그 보고서에 적절히 최소화된 독립 실행형 예제를 포함하십시오
- 버그 보고서 템플릿에서 요청하는 모든 정보를 포함하십시오
- 같은 메시지와 쿼리 스택을 가진 기존 보고서가 있는지 검색하십시오
- 테스트 케이스를
rustfmt로 포맷하십시오 - 퍼징으로 발견한 버그임을 밝히십시오
하지 말아야 할 일:
custom_mir,lang_items,no_core,rustc_attrs등을 포함하되 이에 국한되지 않는 내부 기능을 사용하는 버그를 대량으로 보고하지 마십시오.- rustc를 크래시시키는 것으로 알려진 입력을 퍼저의 시드로 사용하지 마십시오(자세한 내용은 아래 참조).
논의
어떤 ICE가 이미 보고된 것과 중복인지 확실하지 않다면 일단 보고하고, 관련이 있다고 생각되는 이슈에 링크를 걸어 주십시오. 일반적으로 같은 줄에서 발생하더라도 쿼리 스택이 다른 ICE는 대개 서로 다른 버그입니다. 예를 들어, #109020과 #109129는 비슷한 오류 메시지를 냈습니다:
error: internal compiler error: compiler/rustc_middle/src/ty/normalize_erasing_regions.rs:195:90: Failed to normalize <[closure@src/main.rs:36:25: 36:28] as std::ops::FnOnce<(Emplacable<()>,)>>::Output, maybe try to call `try_normalize_erasing_regions` instead
error: internal compiler error: compiler/rustc_middle/src/ty/normalize_erasing_regions.rs:195:90: Failed to normalize <() as Project>::Assoc, maybe try to call `try_normalize_erasing_regions` instead
그러나 두 이슈의 쿼리 스택은 서로 다릅니다:
query stack during panic:
#0 [fn_abi_of_instance] computing call ABI of `<[closure@src/main.rs:36:25: 36:28] as core::ops::function::FnOnce<(Emplacable<()>,)>>::call_once - shim(vtable)`
end of query stack
query stack during panic:
#0 [check_mod_attrs] checking attributes in top-level module
#1 [analysis] running analysis passes on this crate
end of query stack
코퍼스 구축하기
코퍼스를 구축할 때는 rustc를 크래시시키는 것으로 이미 알려진 테스트를 수집하지 않도록 주의하십시오. 그런 테스트를 시드로 사용한 퍼저는 근본 원인이 같은 버그를 생성할 가능성이 더 높습니다. 이를 피하는 가장 간단한 방법은 코퍼스의 각 파일을 루프로 돌면서 ICE를 일으키는지 확인하고, 그렇다면 제거하는 것입니다.
코퍼스를 빌드하려면 다음을 사용하는 것이 좋습니다:
- rustc/rust-analyzer/clippy 테스트 스위트(또는 소스 코드) — 단, 실패를 일으키는 것으로 이미 알려진 테스트는 피하십시오. 이런 테스트는 흔히
//@ failure-status: 101이나//@ known-bug: #NNN같은 주석으로 시작합니다. - 보관된 Glacier 저장소에 있는 이미 수정된 ICE — 단,
ices/에 있는 아직 수정되지 않은 것들은 피하십시오!
추가로 하면 좋은 일
ICE를 보고한 후 Rust 프로젝트에 도움이 되도록 할 수 있는 일은 다음과 같습니다.
- 버그를 이분 탐색하여 언제 도입되었는지 알아내십시오. 회귀를 일으킨 PR / 커밋을 찾았다면 해당 이슈에
S-has-bisection레이블을 달 수 있습니다. 찾지 못했다면 대신E-needs-bisection을 적용하는 것을 고려하십시오. - “주의를 흐트러뜨리는 요소“를 수정하십시오. 이는 구문 오류나 빌림 검사 오류처럼 ICE 발생에 기여하지 않는 테스트 케이스의 문제를 말합니다.
- 테스트 케이스를 최소화하십시오(아래 참조). 성공했다면 해당 이슈에
S-has-mcve레이블을 달 수 있습니다. 그렇지 않다면E-needs-mcve를 적용할 수 있습니다. - 최소화한 테스트 케이스를 크래시 테스트로 rust-lang/rust 저장소에 추가하십시오. 이 작업을 하는 김에 “추적되지 않은” 다른 크래시도 PR에 포함하는 것을 고려하십시오. PR이 병합되면 관련된 모든 이슈에
S-bug-has-test레이블을 다는 것을 잊지 마십시오.
레이블 적용 및 제거도 참조하십시오.
최소화
퍼저가 생성한 입력을 신중하게 최소화하면 도움이 됩니다. 최소화할 때는 원래의 오류를 보존하도록 주의하고, 구문 오류, 타입 검사 오류, 빌림 검사 오류처럼 주의를 흐트러뜨리는 문제를 새로 만들지 않도록 하십시오.
최소화에 도움이 되는 도구가 몇 가지 있습니다. 이러한 도구를 사용하면서 구문 오류, 타입 검사 오류, 빌림 검사 오류를 만들지 않는 방법을 잘 모르겠다면 전체 테스트 케이스와 최소화한 테스트 케이스를 모두 올리십시오. 일반적으로 구문을 인식하는 도구가 가장 짧은 시간에 가장 좋은 결과를 냅니다. treereduce-rust와 picireny는 구문을 인식합니다. halfempty는 구문을 인식하지 않지만 대체로 품질이 높은 도구입니다.
효과적인 퍼징
rustc를 퍼징할 때는 기계어 코드 생성을 피하는 것이 좋을 수 있습니다. 이 작업은 대부분 LLVM이 수행하기 때문입니다. 대신 --emit=mir를 사용해 보십시오.
다양한 컴파일러 플래그로 여러 가지 문제를 찾아낼 수 있습니다. -Zmir-opt-level=4는 기본적으로 실행되지 않는 MIR 최적화 패스를 켜므로 흥미로운 버그를 찾아낼 수도 있습니다. -Zvalidate-mir는 이러한 버그를 찾아내는 데 도움이 됩니다.
직접 빌드한 컴파일러를 퍼징한다면 초당 실행 횟수를 조금이라도 더 늘리기 위해 -C target-cpu=native나 PGO/BOLT까지 사용하여 빌드하는 것이 좋을 수 있습니다. 물론 여러 빌드 구성을 시도해 보고 실제로 어떤 구성이 더 높은 처리량을 내는지 확인하는 것이 가장 좋습니다.
추가 버그를 찾기 위해 디버그 단언을 활성화하여 소스에서 rustc를 빌드할 수도 있지만, 이렇게 하면 매 실행마다 추가 작업이 필요하므로 퍼징 속도가 느려질 수 있습니다. 디버그 단언을 활성화하려면 rustc를 컴파일할 때 bootstrap.toml에 다음을 추가하십시오:
rust.debug-assertions = true
재현하는 데 디버그 단언이 필요한 ICE에는 requires-debug-assertions 태그를 붙여야 합니다.
기존 프로젝트
- fuzz-rustc는 libfuzzer로 rustc를 퍼징하는 방법을 보여 줍니다.
- icemaker는 ICE를 잡아내기 위해 다양한 플래그로 대량의 소스 파일에 rustc 및 기타 도구를 실행합니다.
- tree-splicer는 올바른 구문을 유지하면서 기존 소스 파일을 결합하여 새로운 소스 파일을 생성합니다. 이때 각 파일의 구문 트리를 이어 붙입니다
알림 그룹
알림 그룹은 더 큰 프로젝트에 전념하지 않고도 “조금씩” rustc 개발을 도울 수 있는 쉬운 방법입니다. 알림 그룹은 가입하기 쉬우며(PR만 제출하면 됩니다!) 가입한다고 해서 특별한 의무가 생기지는 않습니다.
알림 그룹에 가입하면 해당 알림 그룹의 기준에 맞는 새 이슈가 발견될 때마다 GitHub에서 핑을 받는 목록에 추가됩니다. 관심이 있다면 이슈를 맡겠다고 요청하고 작업을 시작할 수 있습니다.
물론 새 이슈에 태그가 붙을 때까지 기다릴 필요는 없습니다! 원한다면 알림 그룹의 GitHub 레이블을 사용하여 아직 아무도 맡지 않은 기존 이슈를 검색할 수 있습니다.
알림 그룹 목록
알림 그룹 목록은 다음과 같습니다.
어떤 이슈가 알림 그룹에 적합합니까?
알림 그룹은 주로 독립적인 버그, 특히 중간 우선순위의 버그에 대해 핑을 받습니다.
- 독립적이라는 것은 버그를 고치는 데 대규모 리팩터링이 필요하지 않을 것으로 예상된다는 뜻입니다.
- 중간 우선순위라는 것은 버그가 고쳐지기를 바라지만, 다른 모든 일을 제쳐 두고 고쳐야 할 만큼 급한 문제는 아니라는 뜻입니다. 물론 이런 버그의 위험은 시간이 지나면서 쌓일 수 있다는 점이며, 알림 그룹의 역할은 그런 일이 일어나지 않도록 막는 것입니다!
알림 그룹 가입하기
알림 그룹에 가입하려면 Rust 팀 저장소의 해당 파일에 자신의 GitHub 사용자 이름을 추가하는 PR을 열기만 하면 됩니다. 정확한 방법을 파악하고 수정할 파일을 확인하려면 아래의 “예시 PR“을 참고하십시오.
또한 아직 Rust 팀의 구성원이 아니라면, 파일에 이름을 추가하는 것 외에도 저장소를 체크아웃하고 다음 명령을 실행해야 합니다.
cargo run add-person $your_user_name
예시 PR:
- Apple 그룹에 자신을 추가하는 예시입니다.
- ARM 그룹에 자신을 추가하는 예시입니다.
- Emscripten 그룹에 자신을 추가하는 예시입니다.
- LoongArch 그룹에 자신을 추가하는 예시입니다.
- RISC-V 그룹에 자신을 추가하는 예시입니다.
- WASI 그룹에 자신을 추가하는 예시입니다.
- WebAssembly 그룹에 자신을 추가하는 예시입니다.
- Windows 그룹에 자신을 추가하는 예시입니다.
알림 그룹용으로 이슈 태그하기
이슈를 알림 그룹에 해당하는 것으로 태그하려면 rustbot에게 알림 그룹 이름과 함께 ping 명령을 보내십시오. 예를 들면 다음과 같습니다.
@rustbot ping apple
@rustbot ping arm
@rustbot ping emscripten
@rustbot ping risc-v
@rustbot ping wasi
@rustbot ping wasm
@rustbot ping windows
일부 명령을 더 짧고 기억하기 쉽게 만들기 위해 triagebot.toml 파일에 별칭이 정의되어 있습니다. 예를 들어, 다음 명령은 모두 동일하며 Apple 그룹을 호출합니다.
@rustbot ping apple
@rustbot ping macos
@rustbot ping ios
이러한 별칭은 사람이 더 편하게 사용할 수 있도록 만든 것임을 염두에 두십시오. 별칭은 변경될 수 있습니다. 명령이 항상 유효하도록 보장해야 한다면 별칭보다 전체 명령을 사용하십시오.
다만 이 작업은 컴파일러 팀 구성원이나 기여자만 수행해야 하며, 보통 컴파일러 팀 분류(triage) 작업의 일부로 진행된다는 점에 유의하십시오.
알림 그룹 추가하기
Forge를 참조하십시오.
Apple 알림 그룹
GitHub 레이블: O-macos, O-ios, O-tvos, O-watchos, O-visionos
핑 명령: @rustbot ping apple
이 목록은 Apple 관련 이슈를 진단하고 테스트할 때 도움을 요청하고, macOS/iOS/tvOS/watchOS/visionOS 지원과 관련된 흥미로운 질문을 해결하는 방법에 대한 제안을 구하는 데 사용됩니다.
이 그룹이 하는 일을 더 잘 이해할 수 있도록, 최선의 조치를 결정하기 위해 그룹에 조언을 구했을 법한 질문의 예를 몇 가지 소개합니다.
배포 타깃
Apple 플랫폼에는 “배포 타깃(deployment target)“이라는 개념이 있습니다. 배포 타깃은 *_DEPLOYMENT_TARGET 환경 변수로 제어하며, 바이너리가 실행될 수 있는 최소 OS 버전을 지정합니다.
표준 라이브러리에서 rustc가 사용하는 기본값보다 최신 OS 버전의 API를 사용하면 정적 링커 오류나 동적 링커 오류가 발생합니다. 따라서 extern "C" API에는 해당 API가 도입된 OS 버전을 문서화하도록 제안하고, 그 버전이 rustc가 현재 사용하는 기본값보다 최신이라면 약한 링킹(weak linking)을 사용하도록 제안하십시오.
App Store와 비공개 API
Apple은 문서화되지 않은 API 사용에 매우 엄격합니다. 바이너리에서 문서화되지 않은 API를 호출하지 않고 언급만 해도 App Store에서 거부될 수 있으므로, 변경 사항에서 새 함수를 사용할 때마다 해당 함수가 실제로 공개 API인지 반드시 확인해야 합니다.
예를 들어 Darwin / XNU 커널에는 실제로 futex 시스템 호출이 있지만, 공개 API가 아니므로 std에서 사용할 수 없습니다.
일반적으로 Apple이 공개 API로 간주하려면 해당 API는 다음 조건을 충족해야 합니다.
- 공개 헤더에 포함되어 있어야 합니다(즉, Xcode와 함께 배포되며 특정 플랫폼의
xcrun --show-sdk-path --sdk $SDK아래에서 찾을 수 있는 헤더여야 합니다). - 가용성 속성(
__API_AVAILABLE,API_AVAILABLE등)이 붙어 있어야 합니다.
ARM 알림 그룹
GitHub 레이블: O-ARM
핑 명령어: @rustbot ping arm
이 목록은 ARM 관련 이슈의 진단과 테스트에 도움을 요청하고, ARM 지원과 관련된 흥미로운 질문을 해결할 방법에 대한 제안을 구하는 데 사용됩니다.
이 그룹에는 관련 Zulip 채널(#t-compiler/arm)도 있으며, 이곳에서 질문을 올리고 ARM 관련 주제를 논의할 수 있습니다.
그러니 참여에 관심이 있으시다면 ARM 그룹에 가입하십시오! 그렇게 하려면 팀 저장소인 rust-lang/team 저장소에 PR을 여십시오. 이 예시를 그대로 따르되, 사용자 이름만 본인의 것으로 바꾸십시오!
Emscripten 알림 그룹
GitHub 레이블: O-emscripten
핑 명령: @rustbot ping emscripten
이 목록은 Emscripten 관련 이슈를 진단하고 테스트하는 데 도움을 요청하는 것뿐만 아니라, Emscripten 지원과 관련된 흥미로운 질문을 해결하는 방법에 대한 제안을 구하는 데에도 사용됩니다.
이 그룹에는 관련 Zulip 채널(#t-compiler/wasm)도 있으며, 이곳에서 질문을 하고 Emscripten 관련 주제를 논의할 수 있습니다.
참여에 관심이 있으시다면 Emscripten 그룹에 가입하십시오! 이를 위해 팀 저장소인 rust-lang/team에 PR을 여십시오. 이 예시를 그대로 따르되, 사용자 이름만 본인의 것으로 바꾸십시오!
Fuchsia 알림 그룹
GitHub 레이블: O-fuchsia
핑 명령: @rustbot ping fuchsia
이 목록은 컴파일러나 표준 라이브러리가 Fuchsia 통합을 깨뜨릴 수 있는 방식으로 변경될 때 Fuchsia 메인테이너에게 알리는 데 사용됩니다.
LoongArch 알림 그룹
GitHub 레이블: O-loongarch
핑 명령: @rustbot ping loongarch
이 목록은 LoongArch 관련 이슈를 진단하고 테스트하는 데 도움을 요청하고, LoongArch 지원과 관련된 흥미로운 질문을 해결하는 방법에 대한 제안을 구하는 데 사용됩니다.
이 그룹에는 관련 Zulip 채널(#t-compiler/loong-arch)도 있으며, 이곳에서 질문을 올리고 LoongArch 관련 주제를 논의할 수 있습니다.
참여에 관심이 있으시다면 LoongArch 그룹에 가입하십시오! 이를 위해 팀 저장소인 rust-lang/team에 PR을 여십시오. 이 예시를 따르되, 사용자 이름만 본인의 것으로 바꾸십시오!
RISC-V 알림 그룹
GitHub 레이블: O-riscv
핑 명령: @rustbot ping risc-v
이 목록은 RISC-V 관련 이슈를 진단하고 테스트하는 데 도움을 요청할 때, 그리고 RISC-V 지원에 관한 흥미로운 질문을 해결할 방법에 대한 제안을 구할 때 사용됩니다.
이 그룹에는 관련 Zulip 채널(#t-compiler/risc-v)도 있으며, 이곳에서 질문을 올리고 RISC-V 관련 주제를 논의할 수 있습니다.
참여에 관심이 있으시다면 RISC-V 그룹에 가입하십시오! 이를 위해 팀 저장소인 rust-lang/team에 PR을 여십시오. 이 예시를 그대로 따르되, 사용자 이름만 본인의 것으로 바꾸십시오!
Rust for Linux 알림 그룹
GitHub 레이블: A-rust-for-linux
핑 명령: @rustbot ping rfl
Rust for Linux는 여러 불안정한 플래그와 기능에 의존하므로, 컴파일러나 표준 라이브러리가 Rust for Linux를 깨뜨리는 방식으로 변경될 때 이 목록을 사용하여 Rust for Linux(RfL) 메인테이너에게 알립니다. 알림을 받은 RfL 메인테이너는 이상적으로는 문제 해결을 지원하거나, 문제를 일시적으로 감수하기로 결정하고 RfL CI 작업을 일시적으로 제거하여 CI 차단을 해제해야 합니다.
이 그룹에는 관련 Zulip 채널(#rust-for-linux)도 있으며, 이곳에서 Rust for Linux와 관련된 질문을 하고 주제를 논의할 수 있습니다.
참여에 관심이 있으시면 Zulip에서 Rust for Linux 그룹에 가입하십시오!
WASI 알림 그룹
GitHub 레이블: O-wasi
핑 명령: @rustbot ping wasi
이 목록은 WASI 관련 이슈를 진단하고 테스트하는 데 도움을 요청하고, WASI 지원과 관련된 흥미로운 질문을 해결하는 방법에 대한 제안을 구하는 데 사용됩니다.
이 그룹에는 관련 Zulip 채널(#t-compiler/wasm)도 있으며, 이곳에서 질문을 올리고 WASI 관련 주제를 논의할 수 있습니다.
참여에 관심이 있으시다면 WASI 그룹에 가입하십시오! 이를 위해 rust-lang/team(팀) 저장소에 PR을 여십시오. 이 예시를 그대로 따르되, 사용자 이름만 본인의 것으로 바꾸십시오!
WebAssembly (WASM) 알림 그룹
GitHub 레이블: O-wasm
핑 명령: @rustbot ping wasm
이 목록은 WebAssembly 관련 이슈를 진단하고 테스트하는 데 도움을 요청하고, WASM 지원에 관한 흥미로운 질문을 해결할 방법에 대한 제안을 구하는 데 사용됩니다.
이 그룹에는 관련 Zulip 채널(#t-compiler/wasm)도 있으며, 이곳에서 질문을 올리고 WASM 관련 주제를 논의할 수 있습니다.
참여에 관심이 있으시다면 WASM 그룹에 가입하십시오! 그렇게 하려면 rust-lang/team 팀 저장소에 PR을 여십시오. 이 예시를 그대로 따르되, 사용자 이름만 본인의 것으로 바꾸십시오!
Windows 알림 그룹
GitHub 레이블: O-Windows
핑 명령: @rustbot ping windows
이 목록은 Windows 관련 이슈를 진단하고 테스트할 때 도움을 요청하는 데 사용되며, Windows 지원에 관한 흥미로운 질문을 해결할 방법에 대한 제안을 구하는 데에도 사용됩니다.
이 그룹에는 관련 Zulip 채널(#t-compiler/windows)도 있으며, 이곳에서 질문을 올리고 Windows 관련 주제를 논의할 수 있습니다.
이 그룹이 어떤 일을 하는지 더 잘 이해할 수 있도록, 최선의 방향을 결정하기 위해 이 그룹에 조언을 구했을 법한 질문의 예를 몇 가지 소개합니다.
- 어떤 버전의 MinGW를 지원해야 합니까?
- 레거시 InnoSetup GUI 설치 프로그램을 제거해야 합니까? #72569
- Windows에서 정적 라이브러리에는 어떤 이름을 사용해야 합니까? #29520
그러니 참여에 관심이 있으시다면 Windows 그룹에 가입하십시오! 이를 위해 팀 정보를 관리하는 rust-lang/team 저장소에 PR을 여십시오. 이 예시를 따르되, 사용자 이름만 본인의 것으로 바꾸십시오!
GPU 타깃 알림 그룹
GitHub 레이블: 없음
핑 명령: @rustbot ping gpu-target
이 알림 그룹은 링커 관련 이슈와 이를 컴파일러에 통합하는 작업을 다룹니다.
이 그룹에는 관련 Zulip 스트림(#t-compiler/gpgpu-backend)도 있으며, 이곳에서 질문하고 GPU 관련 주제와 이슈를 논의할 수 있습니다.
참여에 관심이 있다면 부담 없이 이 그룹에 가입하십시오! 가입하려면 팀 정보를 관리하는 rust-lang/team 저장소에 PR을 열고 이 파일에 본인의 GitHub 사용자를 추가하십시오.
rust-lang/rust 라이선스
별도로 명시되지 않는 한, rustc 컴파일러 소스와 표준 라이브러리는 Apache License v2.0과 MIT License의 이중 라이선스로 배포됩니다.
자세한 라이선스 정보는 rust-lang/rust 저장소의 COPYRIGHT 문서에서 확인할 수 있습니다.
리뷰어를 위한 지침
일반적으로 리뷰어는 기여된 코드의 품질뿐만 아니라 해당 코드의 라이선스가 적절한지도 살펴보아야 합니다. 리뷰할 때 주의해야 할 사항에 대한 몇 가지 팁을 아래에 정리해 두었지만, 어떤 코드의 라이선스가 적절한지 확신이 서지 않을 때에는 언제든 안전한 쪽을 택하십시오. 위원회(Council)나 컴파일러 팀 리드에게 연락하여 의견을 구하십시오!
주의해야 할 사항:
- PR 작성자가 다른 출처의 코드를 복사하거나, 포팅하거나, 변형했다고 밝히는 경우입니다.
- 코드에 웹 페이지를 가리키거나 알고리즘의 출처를 설명하는 주석이 있는 경우입니다.
- 알고리즘이나 코드 패턴이 다른 곳에서 복사해 온 것으로 보이는 경우입니다.
- 새 의존성을 추가할 때에는 해당 의존성의 라이선스를 다시 한번 확인하십시오.
이러한 모든 경우에는 해당 출처를 확인하여 Rust의 라이선스와 호환되는 방식으로 라이선스가 부여되어 있는지 확인해야 합니다.
예시
- GNU binutils와 같은 GPL 프로젝트의 C 코드를 포팅하는 것은 허용되지 않습니다. 그렇게 하면 Rust 자체가 GPL로 라이선스되어야 하기 때문입니다.
- 알고리즘 교과서의 코드를 복사하는 것은 허용될 수도 있지만, 일부 알고리즘은 특허로 보호받고 있습니다.
포팅
rustc에 대한 기여, 특히 플랫폼 및 컴파일러 내장 함수(intrinsics)와 관련된 기여에는 다른 프로젝트(주로 LLVM과 GCC)의 작업을 포팅하는 일이 자주 포함됩니다.
다음과 같은 일반적인 규칙이 적용됩니다:
- 다른 작업을 복사할 때에는 원본 라이선스를 준수해야 합니다
- 이는 직접 복사 & 붙여넣기한 경우에 적용됩니다
- 이는 살펴본 뒤 포팅한 코드에도 적용됩니다
일반적으로 다른 코드베이스에서 영감을 얻는 것은 괜찮지만, 코드를 포팅할 때에는 주의를 기울이십시오.
라이브러리 전체를 포팅하는 경우(예: LLVM과 함께 제공되는 C 라이브러리)에는 원본 라이브러리의 라이선스를 유지해야 합니다.
에디션
이 장에서는 rustc에서 에디션 지원이 어떻게 동작하는지 개략적으로 설명합니다. 이 장은 독자가 에디션이 무엇인지 이미 알고 있다고 가정합니다(에디션 가이드를 참고하십시오).
에디션 정의
--edition CLI 플래그는 크레이트에 사용할 에디션을 지정합니다. 이 값은 Session::edition에서 접근할 수 있습니다. 크레이트의 에디션을 확인하는 Session::at_least_rust_2021 같은 편의 함수가 있지만, 전역 세션을 확인할지 스팬을 확인할지 주의해야 합니다. 아래의 에디션 위생을 참고하십시오.
at_least_rust_20xx 편의 메서드 대신 Edition 타입의 비교 기능을 사용하여 span.edition() >= Edition::Edition2021처럼 범위를 검사할 수도 있습니다.
새 에디션 추가하기
새 에디션을 추가하는 작업은 주로 Edition 열거형에 배리언트를 추가한 다음, 그 때문에 깨지는 부분을 모두 고치는 것입니다. 예시는 #94461을 참고하십시오.
기능과 에디션 안정성
에디션이 안정적인지 여부는 Edition 열거형에서 정의합니다. 안정적이지 않은 에디션을 활성화하려면 -Zunstable-options CLI 옵션을 전달해야 합니다.
새 기능을 추가할 때 향후 에디션과 관련된 안정성을 처리하는 방법은 다음 두 가지 중에서 선택할 수 있습니다.
span.at_least_rust_20xx()처럼 스팬의 에디션을 확인하거나(에디션 위생을 참고하십시오)Session::edition을 확인하기만 합니다. 이 경우 기능을 사용할 수 있는지는 암묵적으로 에디션 자체의 안정성에 따라 결정됩니다.- 새 동작을 기능 게이트 뒤에 둡니다.
비교적 단순한 변경이라면 현재 에디션만 확인해도 충분할 수 있습니다. 하지만 규모가 큰 언어 변경이라면 기능 게이트를 만드는 것을 고려해야 합니다. 기능 게이트를 사용하면 다음과 같은 여러 이점이 있습니다.
- 기능 게이트가 있으면 새 기능을 개발하고 실험하기가 더 쉽습니다.
#![feature(…)]속성을 사용할 때 새 기능을 활성화한다는 의도가 분명해집니다.- 아직 완성되지 않은 기능이 이미 완성되어 준비된 에디션별 기능의 테스트를 방해하지 않으므로 에디션을 테스트하기가 더 쉬워집니다.
- 기능이 에디션과 분리되므로, 기능이 준비되었을 때 다음 에디션에 넣을지 여부를 팀이 신중하게 결정하기가 더 쉬워집니다.
기능이 완성되어 준비되면 기능 게이트를 제거할 수 있습니다(이때 코드는 스팬이나 Session의 에디션만 확인하여 기능이 활성화되었는지 판단해야 합니다).
기능을 검사하는 방법에는 몇 가지가 있습니다.
-
에디션에 포함될지 불확실한 매우 실험적인 기능은
tcx.features().my_feature같은 일반 기능 게이트를 구현하고 당분간 에디션을 무시해도 됩니다. -
에디션에 포함될 수도 있는 실험적 기능은
tcx.features().my_feature && span.at_least_rust_20xx()로 게이트를 구현해야 합니다. 이렇게 하면 사용자가 여전히#![feature(my_feature)]를 지정해야 하므로, 이미 준비되어 에디션에 채택된 다른 에디션 기능의 테스트를 방해하지 않습니다. -
에디션에 확실히 포함되기로 확정된 실험적 기능은
tcx.features().my_feature || span.at_least_rust_20xx()로 게이트를 구현하거나, 기능 검사를 아예 없애고span.at_least_rust_20xx()만 확인해야 합니다.
여러 곳에서 기능 게이팅을 해야 한다면, 수정할 곳이 한 곳만 되도록 검사를 하나의 함수에 두는 것을 고려하십시오. 예를 들면 다음과 같습니다.
// An example from Edition 2021 disjoint closure captures.
fn enable_precise_capture(tcx: TyCtxt<'_>, span: Span) -> bool {
tcx.features().capture_disjoint_fields || span.rust_2021()
}
린트가 안정성을 어떻게 처리하는지에 대한 자세한 내용은 아래의 린트와 안정성을 참조하십시오.
에디션 파싱
대부분의 경우 렉서는 에디션과 무관하게 동작합니다. Lexer 내에서는 에디션별 동작에 따라 토큰이 수정될 수 있습니다. 예를 들어, c"foo"와 같은 C 문자열 리터럴은 2021 이전 에디션에서 여러 토큰으로 나뉩니다. 2021 에디션의 예약된 접두사 같은 것들도 이곳에서 처리됩니다.
에디션별 파싱은 비교적 드뭅니다. 한 가지 예로 async fn이 있는데, 이는 토큰의 스팬을 검사하여 2015 에디션인지 판단하고, 그런 경우 오류를 발생시킵니다. 이는 구문이 이미 유효하지 않은 경우에만 할 수 있습니다.
파서에서 에디션 검사를 해야 한다면 일반적으로 토큰의 에디션을 확인해야 합니다. 에디션 위생을 참조하십시오. 드문 경우에는 대신 ParseSess::edition에서 전역 에디션을 확인해야 할 수도 있습니다.
대부분의 에디션별 파싱 동작은 파서가 아니라 마이그레이션 린트로 처리됩니다. 이 방식은 (새로운 구문이 아니라) 구문에 변경이 있을 때 적합합니다. 이렇게 하면 이전 에디션에서는 기존 구문이 계속 동작할 수 있습니다. 그런 다음 린트가 동작의 변경을 검사합니다. 이전 에디션에서는 새 에디션으로의 마이그레이션을 돕기 위해 린트 패스가 마이그레이션 린트를 발생시켜야 합니다. 최신 에디션에서는 대신 코드가 emit_err로 하드 오류를 발생시켜야 합니다. 예를 들어, 더 이상 사용되지 않는 start...end 패턴 구문은 2021 이전 에디션에서는 ellipsis_inclusive_range_patterns 린트를 내보내고, 2021에서는 emit_err 메서드를 통해 하드 오류가 됩니다.
키워드
새 키워드는 에디션 경계를 넘어 도입될 수 있습니다. 이는 Symbol::is_used_keyword_conditional과 같은 함수로 구현되며, 이러한 함수는 키워드가 정의된 순서에 의존합니다.
새 키워드가 도입되면, 해당 키워드를 식별자로 사용하고 있을 수 있는 코드를 자동 마이그레이션으로 전환할 수 있도록 keyword_idents 린트를 갱신해야 합니다(KeywordIdents 참조). 고려할 수 있는 대안으로, 키워드가 사용되는 위치만으로 충분히 구별할 수 있다면 해당 키워드를 약한 키워드로 구현하는 방법이 있습니다.
추가로 고려할 수 있는 선택지는 RFC 3101에서 도입된 k# 접두사입니다. 이를 사용하면 키워드가 도입된 에디션 이전의 에디션에서도 해당 키워드를 사용할 수 있습니다. 이는 현재 구현되어 있지 않습니다.
에디션 위생
스팬에는 해당 스팬이 비롯된 크레이트의 에디션이 표시됩니다. 이것이 무엇을 의미하는지에 대한 사용자 중심의 설명은 에디션 가이드의 매크로 위생을 참조하십시오.
일반적으로 전역 Session의 에디션을 확인하는 대신 토큰 스팬의 에디션을 사용해야 합니다. 예를 들어, sess.at_least_rust_2021() 대신 span.edition().at_least_rust_2021()를 사용하십시오. 이렇게 하면 매크로를 여러 크레이트에 걸쳐 사용할 때에도 올바르게 동작하도록 보장하는 데 도움이 됩니다.
린트
린트는 에디션과 상호작용하기 위한 몇 가지 옵션을 지원합니다. 린트는 미래 비호환 에디션 마이그레이션 린트가 될 수 있으며, 이는 새로운 에디션으로의 마이그레이션을 지원하는 데 사용됩니다. 또는 린트가 에디션별 린트가 될 수도 있으며, 이 경우 특정 에디션부터 기본 레벨이 바뀝니다.
마이그레이션 린트
마이그레이션 린트는 프로젝트를 한 에디션에서 다음 에디션으로 마이그레이션하는 데 사용됩니다. 마이그레이션 린트는 코드를 다시 작성하여 이전 에디션과 다음 에디션 모두에서 성공적으로 컴파일되도록 하는 MachineApplicable 제안으로 구현됩니다. 예를 들어, keyword_idents 린트는 새 키워드와 충돌하는 식별자가 원시 식별자 구문을 사용하도록 바꾸어 충돌을 피합니다(예를 들어 async를 r#async로 바꿉니다).
마이그레이션 린트는 린트 선언에서 FutureIncompatibilityReason::EditionError 또는 FutureIncompatibilityReason::EditionSemanticsChange 미래 비호환 옵션으로 선언해야 합니다:
declare_lint! {
pub KEYWORD_IDENTS,
Allow,
"detects edition keywords being used as an identifier",
@future_incompatible = FutureIncompatibleInfo {
reason: fcw!(EditionError 2018 "slug-of-edition-guide-page")
};
}
이렇게 선언하면 해당 린트는 적절한 rust-20xx-compatibility 린트 그룹에 자동으로 추가됩니다. 사용자가 cargo fix --edition을 실행하면, cargo는 --force-warn rust-20xx-compatibility 플래그를 전달하여 에디션 마이그레이션 중에 이러한 린트가 모두 나타나도록 강제합니다. 또한 Cargo는 다른 린트가 에디션 마이그레이션을 방해하지 않도록 --cap-lints=allow를 전달합니다.
예제 코드가 올바른 에디션을 설정하는지 확인하십시오. 예제는 이전 에디션을 기준으로 작성해야 하며, 마이그레이션 경고가 어떻게 표시되는지 보여 주어야 합니다. 예를 들어, 2024 마이그레이션을 위한 이 린트는 2021 에디션의 예제를 보여 줍니다:
declare_lint! {
/// The `keyword_idents_2024` lint detects ...
///
/// ### Example
///
/// ```rust,edition2021
/// #![warn(keyword_idents_2024)]
/// fn gen() {}
/// ```
///
/// {{produces}}
}
마이그레이션 린트의 기본값은 Allow 또는 Warn 중 하나일 수 있습니다. Allow인 경우, 사용자가 에디션 마이그레이션을 수동으로 수행하고 있거나 마이그레이션 중에 문제가 발생하지 않는 한 사용자는 대개 이 경고를 보지 못합니다. 대부분의 마이그레이션 린트는 Allow입니다.
기본값이 Warn인 경우, 모든 에디션의 사용자가 이 경고를 보게 됩니다. 모든 사람이 해당 변경 사항을 알고 모든 에디션에서 코드를 업데이트하도록 권장하는 것이 중요하다고 생각하는 경우에만 Warn을 사용하십시오. 많은 프로젝트에 영향을 미치는 새로운 기본 경고 린트는 사용자에게 큰 혼란과 불만을 줄 수 있다는 점에 유의하십시오. 에디션이 안정화되고 몇 년이 지난 후에 Allow를 Warn으로 바꾸는 것을 고려할 수 있습니다. 이렇게 하면 아직 새 에디션으로 업데이트하지 않은 비교적 소수의 뒤처진 사용자에게만 경고가 표시됩니다.
에디션별 린트
린트는 특정 에디션부터 다른 레벨을 갖도록 표시할 수 있습니다. 린트 선언에서 @edition 마커를 사용하십시오:
declare_lint! {
pub SOME_LINT_NAME,
Allow,
"my lint description",
@edition Edition2024 => Warn;
}
여기서 SOME_LINT_NAME은 2024 이전의 모든 에디션에서는 기본값이 Allow이고, 그 이후에는 Warn이 됩니다.
다른 선택지도 있으므로 이 방법은 일반적으로 신중하게 사용해야 합니다:
-
에디션과 무관하고 영향이 작은 스타일 변경은 모든 에디션에서 해당 린트를
Warn으로 설정하기만 하면 됩니다. 사람들이 다른 작성 방식을 채택하기를 원한다면, 모든 프로젝트에서 이 린트가 표시되도록 하는 변경을 과감히 커밋하십시오.기본적으로 경고하는 새 린트가 많은 프로젝트에 걸리면 사용자에게 큰 혼란과 불만을 줄 수 있으므로 주의하십시오.
-
새 에디션에서는 새 스타일을 따르지 않으면 하드 오류가 되도록 바꾸고, 마이그레이션 린트를 사용하여 프로젝트를 새 스타일로 자동 변환하십시오. 예를 들어,
ellipsis_inclusive_range_patterns는 2021에서 하드 오류이며, 이전의 모든 에디션에서는 경고를 냅니다.이러한 변경은 에디션이 안정화된 후에는 추가할 수 없으므로 주의하십시오.
-
마이그레이션 린트도 시간이 지나면서 바뀔 수 있습니다. 예를 들어, 마이그레이션 린트는 처음에 기본값
Allow로 시작할 수 있습니다. 마이그레이션을 수행하는 사람들의 코드는 자동으로 새 코드로 갱신됩니다. 그런 다음 몇 년 후에 이전 에디션에서 해당 린트를Warn으로 바꿀 수 있습니다.예를 들어
anonymous_parameters는 2018 에디션 마이그레이션 린트(이자 2018에서는 하드 오류)였으며, 이전 에디션에서는 기본값이Allow였습니다. 그리고 3년 후에는 이전의 모든 에디션에서Warn으로 변경되어, 모든 사용자가 해당 스타일이 단계적으로 폐지되고 있다는 경고를 받게 되었습니다. 이것이 처음부터 경고였다면 많은 프로젝트에 영향을 주어 큰 혼란을 일으켰을 것입니다. 이를 에디션의 일부로 만들었기 때문에, 대부분의 사용자는 결국 새 에디션으로 업데이트하면서 마이그레이션을 통해 처리되었습니다.Warn으로 전환한 것은 업데이트하지 않은 소수의 뒤처진 사용자에게만 영향을 주었습니다.
린트와 안정성
린트는 불안정으로 표시할 수 있으며, 이는 새 에디션 기능을 개발하면서 마이그레이션 린트를 테스트해 보고 싶을 때 유용할 수 있습니다. 기능 게이트는 다음과 같이 린트 선언에 지정할 수 있습니다:
declare_lint! {
pub SOME_LINT_NAME,
Allow,
"my cool lint",
@feature_gate = sym::my_feature_name;
}
그러면 사용자가 적절한 #![feature(my_feature_name)]를 지정한 경우에만 린트가 발생합니다. 다만 마이그레이션을 테스트하는 crater 실행을 할 때가 되면 기능 게이트를 제거해야 한다는 점에 주의하십시오.
또는 기능 게이트 없이, 곧 나올 불안정한 에디션을 위한 기본 허용(allow-by-default) 마이그레이션 린트를 구현할 수도 있습니다. 기술적으로는 사용자가 에디션이 안정화되기 전에 해당 린트를 활성화할 수 있겠지만, 대부분은 새 린트가 존재한다는 것을 알아차리지 못할 것이며, 아무것도 방해하거나 망가뜨리지 않을 것입니다.
관용구 린트
2018 에디션에는 rust-2018-idioms 린트 그룹 아래에 “관용구 린트(idiom lints)“라는 개념이 있었습니다. 이 개념은 새로운 관용적 스타일을 rust-2018-compatibility 린트 그룹 아래의 강제 마이그레이션과 분리된 별도의 린트 그룹에 두어, 사람들이 특정 에디션 변경 사항을 선택적으로 적용하는 방식에 어느 정도 유연성을 주려는 것이었습니다.
전반적으로 이 접근 방식은 그다지 잘 작동하지 않은 것으로 보이며, 앞으로 관용구 그룹을 사용할 가능성은 낮습니다.
표준 라이브러리 변경 사항
프렐루드
각 에디션에는 해당 에디션 전용의 표준 라이브러리 프렐루드가 함께 제공됩니다. 이들은 core::prelude와 std::prelude에 일반 모듈로 구현되어 있습니다. 프렐루드에 새 항목을 추가할 수 있지만, 이것이 사용자의 기존 코드와 충돌할 수 있다는 점에 주의하십시오. 일반적으로 충돌을 피하려면 마이그레이션 린트를 사용하여 기존 코드를 마이그레이션해야 합니다. 예를 들어, rust_2021_prelude_collisions는 2021 에디션의 새 트레이트와의 충돌을 처리하는 데 사용됩니다.
사용자 정의 언어 동작
일반적으로 표준 라이브러리에는 호환성을 깨는 변경을 할 수 없습니다. 드물게는 팀이 동작 변경이 이 규칙을 깰 만큼 중요하다고 판단할 수도 있습니다. 단점은 이전 시그니처나 동작과 새 시그니처나 동작 중 어느 것을 언제 사용해야 하는지 구별할 수 있도록 컴파일러에서 특별한 처리가 필요하다는 것입니다.
한 가지 예는 배열의 into_iter()에 대한 메서드 해석 변경입니다. 이는 IntoIterator 트레이트에 #[rustc_skip_array_during_method_dispatch] 속성을 붙여 구현되었으며, 이 속성은 컴파일러가 에디션에 따라 다른 트레이트 해석 방식을 고려하도록 지시합니다.
또 다른 예는 panic! 매크로 변경입니다. 이를 위해 여러 패닉 매크로를 정의하고, 내장 패닉 매크로 구현이 이를 확장할 적절한 방법을 결정하도록 해야 했습니다. 여기에는 기존 코드를 새로운 형태로 조정하기 위한 non_fmt_panics 마이그레이션 린트도 포함되었는데, 이 린트는 패닉 매크로의 사용을 감지하기 위해 rustc_diagnostic_item 속성이 필요했습니다.
일반적으로 매우 가치가 높은 상황이 아니라면 이러한 특수한 경우는 피하는 것이 좋습니다.
표준 라이브러리 에디션 마이그레이션
표준 라이브러리 자체의 에디션을 업데이트하는 작업은 대략 다음 과정으로 이루어집니다.
- 새로 안정화된 에디션이 베타에 도달하고 부트스트랩 컴파일러가 업데이트될 때까지 기다리십시오.
- 마이그레이션 린트를 적용하십시오. 일부 코드는 외부 서브모듈1에 있고 표준 라이브러리는 조건부 컴파일을 많이 사용하므로, 이 과정은 복잡할 수 있습니다. 또한 표준 라이브러리 자체에
cargo fix --edition을 실행하는 것은 현실적이지 않을 수 있습니다. 한 가지 방법은 각 린트마다 각 크레이트의 맨 위에#![warn(...)]를 개별적으로 추가하고,./x check library를 실행하고, 마이그레이션을 적용한 다음,#![warn(...)]를 제거하고 각 마이그레이션을 별도로 커밋하는 것입니다. 전체 커버리지를 확보하려면 여러 타깃에 대해--target과 함께./x check를 실행해야 할 가능성이 높습니다(그렇지 않으면 CI를 통과시키는 데 며칠에서 몇 주를 소비하게 될 가능성이 큽니다)2. 더 많은 팁은 고급 마이그레이션 가이드도 참고하십시오.backtrace-rs에 마이그레이션을 적용하십시오. 2024 에디션 예시입니다. 이 작업은 크레이트 자체의 에디션을 업데이트하지 않는다는 점에 유의하십시오. 해당 크레이트는 crates.io에 독립적으로 배포되므로, 에디션을 업데이트하면 최소 Rust 버전이 제한되기 때문입니다. 해당 크레이트의 에디션이 업데이트될 때까지 회귀를 방지하기 위해#![deny()]속성을 몇 개 추가하는 것을 고려하십시오.stdarch에 마이그레이션을 적용하고, 에디션과 포매팅을 업데이트하십시오. 2024 에디션 예시입니다.- backtrace와 stdarch 서브모듈을 업데이트하는 PR을 올리고, 해당 PR이 병합될 때까지 기다리십시오.
- 표준 라이브러리 크레이트에 마이그레이션 린트를 적용하고, 에디션을 업데이트하십시오.
core부터 시작하여 한 번에 한 크레이트씩 작업하는 것이 좋습니다. 2024 에디션 예시입니다.
에디션 안정화하기
에디션 팀이 승인하면 에디션을 안정화하는 절차는 대략 다음과 같습니다.
LATEST_STABLE_EDITION을 업데이트하십시오.Edition::is_stable을 업데이트하십시오.- 에디션을 번호로 언급하는 문서를 모두 찾아 업데이트하십시오.
//@ edition헤더를 사용하는 테스트를 정리하여-Zunstable-options플래그를 제거하고, 해당 테스트가 실제로 안정화되었는지 확인하십시오. 참고: 이 작업은 자동화하는 것이 이상적입니다. #133582를 참조하십시오.- 변경되는 테스트는 모두 bless하십시오.
lint-docs가 새 에디션을 기본값으로 사용하도록 업데이트하십시오.
2024 에디션 예시를 참조하십시오.
-
향후에는 이 서브모듈들을
rust-lang/rust로 가져오는 방식으로 바뀌기를 기대합니다. ↩ -
또한 여러 타깃에 대해 많은 테스트를 수행해야 할 가능성이 높으며, 이때 docker 테스트가 유용합니다. ↩
컴파일러 부트스트래핑
부트스트래핑은 컴파일러를 사용하여 그 컴파일러 자신을 컴파일하는 과정입니다. 더 정확히 말하면, 이전 버전의 컴파일러를 사용하여 같은 컴파일러의 새 버전을 컴파일하는 것을 의미합니다.
이는 닭이 먼저냐 달걀이 먼저냐 하는 역설을 불러옵니다. 최초의 컴파일러는 어디에서 왔을까요? 최초의 컴파일러는 다른 언어로 작성되었을 수밖에 없습니다. Rust의 경우, 최초의 컴파일러는 OCaml로 작성되었습니다. 하지만 그 컴파일러는 오래전에 버려졌으며, 최신 버전의 rustc를 빌드하는 유일한 방법은 그보다 약간 이전 버전을 사용하는 것입니다.
이것이 바로 Rust의 부트스트랩 빌드 시스템이 작동하는 방식입니다. rustc의 현재 베타 릴리스를 다운로드한 다음, 이를 사용하여 새 컴파일러를 컴파일합니다.
이 절에서는 부트스트랩이 무엇을 하는지를 개략적으로 살펴본 다음, 부트스트랩이 그 일을 어떻게 하는지를 개략적으로 소개합니다.
또한 디버깅 방법을 알아보려면 부트스트랩 디버깅을 참고하십시오.
부트스트래핑이 하는 일
참고: 이 문서는 주로 사용자 대상 정보를 다룹니다. 부트스트랩 내부 구조에 대해서는 bootstrap/README.md를 참고하십시오.
부트스트래핑의 단계
개요
- 0단계: 미리 컴파일된 컴파일러와 표준 라이브러리
- 1단계: 이전 컴파일러로 현재 코드를 빌드
- 2단계: 진정한 현재 컴파일러
- 3단계: 동일 결과 테스트
rustc 컴파일은 여러 단계에 걸쳐 이루어집니다. 다음은 RustConf 2022에서 Jynn Nelson이 발표한 부트스트래핑에 관한 강연의 다이어그램을 바탕으로 만든 다이어그램이며, 자세한 설명은 아래에 있습니다.
A, B, C, D는 부트스트래핑 단계의 순서를 나타냅니다. 파란색 노드는 다운로드되고, 노란색 노드는 stage0 컴파일러로 빌드되며, 초록색 노드는 stage1 컴파일러로 빌드됩니다.
graph TD
s0c["stage0 compiler (1.86.0-beta.1)"]:::downloaded -->|A| s0l("stage0 std (1.86.0-beta.1)"):::downloaded;
s0c & s0l --- stepb[ ]:::empty;
stepb -->|B| s0ca["stage0 compiler artifacts (1.87.0-dev)"]:::with-s0c;
s0ca -->|copy| s1c["stage1 compiler (1.87.0-dev)"]:::with-s0c;
s1c -->|C| s1l("stage1 std (1.87.0-dev)"):::with-s1c;
s1c & s1l --- stepd[ ]:::empty;
stepd -->|D| s1ca["stage1 compiler artifacts (1.87.0-dev)"]:::with-s1c;
s1ca -->|copy| s2c["stage2 compiler"]:::with-s1c;
classDef empty width:0px,height:0px;
classDef downloaded fill: lightblue;
classDef with-s0c fill: yellow;
classDef with-s1c fill: lightgreen;
0단계: 미리 컴파일된 컴파일러
stage0 컴파일러는 기본적으로 아주 최근의 베타 rustc 컴파일러와 그에 딸린 동적 라이브러리이며, 부트스트랩이 이를 대신 다운로드해 줍니다. (bootstrap.toml에서 설정하여 stage0을 다른 것으로 바꿀 수도 있습니다.)
그런 다음 미리 컴파일된 stage0 컴파일러는 미리 컴파일된 stage0 std로 src/bootstrap과 compiler/rustc를 컴파일하는 데에만 사용됩니다.
stage1 컴파일러를 빌드할 때는 미리 컴파일된 stage0 컴파일러와 std를 사용한다는 점에 유의하십시오. 따라서 트리에서 새로 빌드한 std를 갖춘 컴파일러를 사용하려면 stage2 컴파일러를 빌드해야 합니다.
여기에는 두 가지 개념이 관련되어 있습니다. 하나는 컴파일러(및 그 의존성 집합)이고, 다른 하나는 그 컴파일러의 ‘타깃’ 또는 ‘오브젝트’ 라이브러리(std와 rustc)입니다. 둘 다 단계적으로 빌드되지만, 서로 엇갈린 방식으로 진행됩니다.
1단계: 이전 컴파일러로 현재 코드를 빌드
그런 다음 rustc 소스 코드를 stage0 컴파일러로 컴파일하여 stage1 컴파일러를 만듭니다.
2단계: 진정한 현재 컴파일러
그런 다음 트리 내 std와 함께 stage1 컴파일러를 사용해 컴파일러를 다시 빌드하여 stage2 컴파일러를 만듭니다.
stage1 컴파일러 자체는 미리 컴파일된 stage0 컴파일러와 std로 빌드되었으므로, 작업 디렉터리에 있는 소스로 빌드된 것이 아닙니다. 즉, stage0 컴파일러가 생성한 ABI는 stage1 컴파일러가 만들었을 ABI와 일치하지 않을 수 있으며, 이로 인해 동적 라이브러리, 테스트, 그리고 rustc_private를 사용하는 도구에서 문제가 생길 수 있습니다.
proc_macro 크레이트는 proc_macro::bridge라는 C FFI 계층을 통해 이 문제를 피하므로 stage1과 함께 사용할 수 있다는 점에 유의하십시오.
stage2 컴파일러는 rustup을 비롯한 다른 모든 설치 방법으로 배포되는 컴파일러입니다. 하지만 먼저 이전 컴파일러로 새 컴파일러를 빌드한 다음, 그 컴파일러로 새 컴파일러 자신을 다시 빌드해야 하므로 빌드하는 데 매우 오랜 시간이 걸립니다.
개발할 때는 보통 --stage 1 플래그만 사용하여 빌드하면 됩니다. 컴파일러 빌드하기를 참고하십시오.
3단계: 동일 결과 테스트
3단계는 선택 사항입니다. 새 컴파일러가 제대로 동작하는지 확인하려면 stage2 컴파일러로 라이브러리를 빌드할 수 있습니다. 무언가 망가지지 않았다면 그 결과는 이전과 동일해야 합니다.
단계 빌드하기
./x 스크립트는 편의를 위해 각 하위 명령마다 사용자가 의도했을 가능성이 가장 높은 단계를 선택하려고 합니다. 다음은 몇 가지 x 명령과 각 명령의 기본 단계입니다:
check:--stage 1clippy:--stage 1doc:--stage 1build:--stage 1test:--stage 1dist:--stage 2install:--stage 2bench:--stage 2
--stage N을 명시적으로 전달하면 언제든지 단계를 재정의할 수 있습니다.
단계에 대한 자세한 내용은 아래를 참고하십시오.
부트스트래핑의 복잡한 점
빌드 시스템은 현재 베타 컴파일러를 사용하여 stage1 부트스트래핑 컴파일러를 빌드하므로, 컴파일러 소스 코드는 일부 기능이 베타에 도달하기 전까지 그 기능을 사용할 수 없습니다(그렇지 않으면 베타 컴파일러가 해당 기능을 지원하지 않기 때문입니다). 반면에 컴파일러 내장 함수와 내부 기능의 경우에는 해당 기능을 반드시 사용해야 합니다. 또한 컴파일러는 nightly 기능(#![feature(...)])을 광범위하게 사용합니다. 이 문제를 어떻게 해결할 수 있습니까?
사용되는 방법은 두 가지입니다:
- 빌드 시스템은
stage0으로 빌드할 때--cfg bootstrap을 설정하므로,cfg(not(bootstrap))를 사용하여stage1로 빌드할 때에만 기능을 사용하도록 할 수 있습니다. 이런 방식으로--cfg bootstrap을 설정하는 것은 막 안정화된 기능, 즉stage0으로 빌드할 때는#![feature(...)]가 필요하지만stage1에서는 필요하지 않은 기능에 사용됩니다. - 빌드 시스템은
RUSTC_BOOTSTRAP=1을 설정합니다. 이 특수 변수는 Rust의 안정성 보장을 깨뜨린다는 것을 의미합니다. 즉,nightly가 아닌 컴파일러에서도#![feature(...)]를 사용할 수 있게 합니다.RUSTC_BOOTSTRAP=1설정은 컴파일러를 부트스트래핑할 때를 제외하고는 절대 사용해서는 안 됩니다.
부트스트랩 단계 이해하기
개요
이 절에서는 개별 부트스트랩 단계를 자세히 살펴봅니다.
./x가 사용하는 규칙은 다음과 같습니다:
--stage N플래그는 N단계 컴파일러(stageN/rustc)를 실행한다는 의미입니다.- “N단계 산출물“은 N단계 컴파일러가 생성한 빌드 산출물입니다.
- 스테이지 N+1 컴파일러는 스테이지 N 산출물로 조립됩니다. 이 과정을 업리프팅(uplifting)이라고 합니다.
빌드 산출물
./x로 빌드할 수 있는 것은 모두 빌드 아티팩트입니다. 빌드 산출물에는 다음이 포함되며, 이에 국한되지 않습니다:
stage0-rustc/rustc-main과 같은 바이너리stage0-sysroot/rustlib/libstd-6fae108520cf72fe.so와 같은 공유 오브젝트stage0-sysroot/rustlib/libstd-6fae108520cf72fe.rlib와 같은 rlib 파일doc/std와 같이 rustdoc이 생성한 HTML 파일
예시
./x test tests/ui는stage1컴파일러를 빌드하고 그 컴파일러에 대해compiletest를 실행한다는 뜻입니다. 컴파일러를 작업하고 있다면 보통 이 테스트 명령을 사용하면 됩니다../x test --stage 0 library/std는rustc를 소스에서 빌드하지 않고 표준 라이브러리에 대해 테스트를 실행한다는 의미입니다(‘stage0으로 빌드한 다음 산출물을 테스트’). 표준 라이브러리를 작업하고 있다면 보통 이 테스트 명령을 사용하면 됩니다../x build --stage 0은 stage0rustc로 빌드한다는 뜻입니다../x doc --stage 1은 stage0rustdoc을 사용해 문서를 생성한다는 뜻입니다.
하면 안 되는 것의 예시
./x test --stage 0 tests/ui는 쓸모가 없습니다. 이 명령은 베타 컴파일러에 대해 테스트를 실행하며rustc를 소스에서 빌드하지 않습니다. 대신stage1을 소스에서 빌드하는test tests/ui를 사용하십시오../x test --stage 0 compiler/rustc는 컴파일러를 빌드하지만 테스트는 전혀 실행하지 않습니다. 이 명령은cargo test -p rustc를 실행하지만,cargo는 Rust의 테스트를 이해하지 못합니다. 이 명령을 사용할 필요는 없을 것입니다. 대신 (인자 없이)test를 사용하십시오../x build --stage 0 compiler/rustc는 컴파일러를 빌드하지만libstd는 물론libcore조차 빌드하지 않습니다. 대부분의 경우에는 대신./x build library를 사용하는 것이 좋으며, 이렇게 하면 언어 항목을 정의할 필요 없이 프로그램을 컴파일할 수 있습니다.
빌드와 실행의 차이
요약하면, 스테이지 0은 stage0 컴파일러를 사용하여 stage0 산출물을 만들고, 이 산출물은 나중에 끌어올려져 stage1 컴파일러가 됩니다.
0을 제외한 각 스테이지에서는 두 가지 주요 단계가 수행됩니다:
std가 스테이지 N 컴파일러로 컴파일됩니다.- 그
std는 스테이지 N 산출물(스테이지 N+1 컴파일러)을 포함하여 스테이지 N 컴파일러로 빌드된 프로그램에 링크됩니다.
스테이지 N 산출물을 스테이지 N 컴파일러로 빌드하는 “또 하나의” 프로그램으로 생각하면 어느 정도 직관적입니다. build --stage N compiler/rustc는 스테이지 N 산출물을 스테이지 N 컴파일러가 빌드한 std에 링크하는 것입니다.
스테이지와 std
여기에는 두 개의 std 라이브러리가 관여한다는 점에 유의하십시오:
- 스테이지 N-1로 빌드되어
stageN/rustc에 링크된 라이브러리(스테이지 N-1std) - stage N에서 빌드된
stageN/rustc로 프로그램을 컴파일하는 데 사용하는 라이브러리(stage Nstd).
stage N 컴파일러로 쓸모 있는 작업을 하려면 stage N std가 사실상 반드시 필요합니다. 이것이 없으면 #![no_core]를 사용하는 프로그램만 컴파일할 수 있는데, 이는 그다지 유용하지 않습니다!
이 둘이 달라야 하는 이유는 반드시 ABI 호환이 보장되지는 않기 때문입니다. nightly에는 베타에 없는 새로운 레이아웃 최적화, MIR 변경, 또는 Rust 메타데이터에 대한 그 밖의 변경이 있을 수 있습니다.
--keep-stage 1 library/std가 쓰이는 곳도 바로 여기입니다. 컴파일러에 대한 변경 대부분은 실제로 ABI를 바꾸지 않으므로, 일단 stage1에서 std를 만들어 두었다면 아마 다른 컴파일러에서도 그대로 재사용할 수 있을 것입니다. ABI가 바뀌지 않았다면 그대로 진행하면 되며, 그 std를 다시 컴파일하는 데 시간을 들일 필요가 없습니다. --keep-stage 플래그는 단순히 빌드 스크립트가 이전 컴파일 결과에 문제가 없다고 가정하도록 지시하며, cargo 호출을 건너뛰고 해당 산출물을 적절한 위치로 복사합니다.
rustc 크로스 컴파일
크로스 컴파일은 다른 아키텍처에서 실행될 코드를 컴파일하는 과정입니다. 예를 들어 x86 머신을 사용해 ARM용 rustc를 빌드하려는 경우가 있을 수 있습니다. 크로스 컴파일할 때는 stage2 std를 빌드하는 방식이 다릅니다.
이는 ./x가 다음과 같은 로직을 사용하기 때문입니다. HOST와 TARGET이 같으면 stage2에 stage1 std를 재사용합니다! 이것이 타당한 이유는 stage1 std가 stage1 컴파일러, 즉 현재 체크아웃한 소스 코드를 사용하는 컴파일러로 컴파일되었기 때문입니다. 따라서 이는 stage2/rustc가 컴파일할 std와 동일해야 합니다(그러므로 ABI 호환도 됩니다).
하지만 크로스 컴파일할 때는 stage1 std가 호스트에서만 실행됩니다. 그래서 stage2 컴파일러가 타깃용 std를 다시 컴파일해야 합니다.
(표에서 stage2가 호스트가 아닌 std 타깃만 빌드하는 것을 확인하십시오.)
’sysroot’란 무엇입니까?
cargo로 프로젝트를 빌드하면 의존성의 빌드 산출물은 보통 target/debug/deps에 저장됩니다. 여기에는 cargo가 알고 있는 의존성만 들어 있으며, 특히 표준 라이브러리는 없습니다. 그렇다면 std나 proc_macro는 어디에서 오는 것입니까? 이들은 컴파일러가 런타임에 빌드 산출물을 불러오는 여러 디렉터리의 루트인 sysroot에서 옵니다. 다만 sysroot에는 표준 라이브러리만 저장되는 것이 아니라, 런타임에 불러와야 하는 모든 것이 포함됩니다. 여기에는 다음이 포함됩니다(단, 이에 국한되지는 않습니다):
libstd/libtest/libproc_macro라이브러리.rustc_private를 사용할 때의 컴파일러 크레이트 자체. 트리 내부에서는 이들이 항상 존재하며, 트리 외부에서는rustup으로rustc-dev를 설치해야 합니다.- LLVM 프로젝트의 공유 오브젝트 파일
libLLVM.so. 트리 내부에서는 이것을 소스에서 빌드하거나 CI에서 내려받으며, 트리 외부에서는rustup으로llvm-tools-preview를 설치해야 합니다.
지금까지 나열한 산출물은 모두 컴파일러의 런타임 의존성입니다. 이들은 rustc --print sysroot로 확인할 수 있습니다:
$ ls $(rustc --print sysroot)/lib
libchalk_derive-0685d79833dc9b2b.so libstd-25c6acf8063a3802.so
libLLVM-11-rust-1.50.0-nightly.so libtest-57470d2aa8f7aa83.so
librustc_driver-4f0cc9f50e53f0ba.so libtracing_attributes-e4be92c35ab2a33b.so
librustc_macros-5f0ec4a119c6ac86.so rustlib
표준 라이브러리에도 런타임 의존성이 있습니다! 이들은 lib/ 바로 아래가 아니라 lib/rustlib/에 있습니다.
$ ls $(rustc --print sysroot)/lib/rustlib/x86_64-unknown-linux-gnu/lib | head -n 5
libaddr2line-6c8e02b8fedc1e5f.rlib
libadler-9ef2480568df55af.rlib
liballoc-9c4002b5f79ba0e1.rlib
libcfg_if-512eb53291f6de7e.rlib
libcompiler_builtins-ef2408da76957905.rlib
lib/rustlib/ 디렉터리에는 hashbrown, cfg_if 같은 라이브러리가 들어 있습니다. 이들은 표준 라이브러리의 공개 API에 속하지 않지만 표준 라이브러리를 구현하는 데 사용됩니다. 또한 lib/rustlib/는 링커의 검색 경로에 포함되지만, lib는 절대 검색 경로에 포함되지 않습니다.
-Z force-unstable-if-unmarked
lib/rustlib/는 검색 경로에 포함되므로 여기에 어떤 크레이트를 넣을지 신중해야 합니다. 특히 표준 라이브러리를 제외한 모든 크레이트는 -Z force-unstable-if-unmarked 플래그로 빌드됩니다. 따라서 이들을 불러오려면 #![feature(rustc_private)]를 사용해야 합니다(항상 사용할 수 있는 표준 라이브러리와는 다릅니다).
-Z force-unstable-if-unmarked 플래그는 올바른 크레이트가 unstable로 표시되도록 강제하는 데 도움이 되는 여러 목적을 가지고 있습니다. 이 플래그는 주로 rustc와 표준 라이브러리가 그 자체로는 staged_api를 사용하지 않는 crates.io의 임의의 크레이트에 링크할 수 있도록 하기 위해 도입되었습니다. rustc 또한 이 플래그를 이용해 자신의 모든 크레이트를 rustc_private 기능과 함께 unstable로 표시하므로, 각 크레이트를 일일이 unstable로 표시하지 않아도 됩니다.
이 플래그는 부트스트랩 스크립트에 의해 rustc 전체와 표준 라이브러리에 자동으로 적용됩니다. 컴파일러와 그 모든 의존성이 sysroot에 담겨 모든 사용자에게 배포되기 때문에 이것이 필요합니다.
이 플래그의 효과는 다음과 같습니다.
- 크레이트 자체가
stable또는unstable로 표시되어 있지 않으면, 해당 크레이트를rustc_private기능과 함께 “unstable“로 표시합니다. - 이 크레이트들이 속성 없이도 강제로 불안정하게 지정된 다른 크레이트에 접근할 수 있게 합니다. 일반적으로 크레이트가 다른
unstable크레이트를 사용하려면#![feature(rustc_private)]속성이 필요합니다. 그러나 그렇게 하면 crates.io의 크레이트는feature(rustc_private)속성을 갖지 않으므로 자신의 의존성에 접근할 수 없게 됩니다. 하지만 모든 것이-Z force-unstable-if-unmarked로 컴파일됩니다.
-Z force-unstable-if-unmarked를 사용하지 않는 코드가 강제로 불안정하게 지정된 이 크레이트들에 접근하려면 #![feature(rustc_private)] 크레이트 속성을 포함해야 합니다. 이는 Miri나 clippy처럼 rustc 자체를 링크하는 도구에 필요합니다.
sysroot에 관한 더 많은 논의는 다음에서 찾아볼 수 있습니다.
sysroot에서 불러오는 의존성에extern crate를 사용하는 이유를 설명하는 rustdoc PR- Zulip에서의 sysroot에 관한 논의
- rustdoc를 트리 외부에서 빌드하는 것에 관한 논의
bootstrap이 호출하는 명령에 플래그 전달하기
편리하게도 ./x를 사용하면 부트스트래핑할 때 rustc와 cargo에 스테이지별 플래그를 전달할 수 있습니다. RUSTFLAGS_BOOTSTRAP 환경 변수는 부트스트랩 스테이지(stage0)에 RUSTFLAGS로 전달되며, RUSTFLAGS_NOT_BOOTSTRAP은 이후 스테이지의 산출물을 빌드할 때 전달됩니다. RUSTFLAGS도 동작하지만 bootstrap 자체의 빌드에도 영향을 미치므로, 이를 사용하고 싶은 경우는 드물 것입니다. 마지막으로 MAGIC_EXTRA_RUSTFLAGS는 cargo 캐시를 우회하여 모든 의존성을 다시 컴파일하지 않고도 rustc에 플래그를 전달합니다.
RUSTDOCFLAGS,RUSTDOCFLAGS_BOOTSTRAP,RUSTDOCFLAGS_NOT_BOOTSTRAP은RUSTFLAGS와 비슷하지만rustdoc용입니다.CARGOFLAGS는 cargo 자체에 인자를 전달합니다.CARGOFLAGS_BOOTSTRAP과CARGOFLAGS_NOT_BOOTSTRAP은RUSTFLAGS_BOOTSTRAP과 비슷하게 동작합니다.--test-args는 테스트 러너에 인자를 그대로 전달합니다.tests/ui의 경우 테스트 러너는compiletest입니다. 단위 테스트와 문서 테스트의 경우 테스트 러너는libtest러너입니다.
대부분의 테스트 러너는 --help를 지원하며, 이를 사용해 해당 러너가 받는 옵션을 확인할 수 있습니다.
환경 변수
부트스트래핑 중에는 컴파일러 내부에서 사용하는 환경 변수가 많이 쓰입니다. rustc의 중간 버전을 실행하려는 경우, 이러한 환경 변수 중 일부를 직접 설정해야 할 때가 있습니다. 그렇지 않으면 다음과 같은 오류가 발생합니다.
thread 'main' panicked at 'RUSTC_STAGE was not set: NotPresent', library/core/src/result.rs:1165:5
./stageN/bin/rustc가 환경 변수에 관한 오류를 낸다면, 이는 보통 무언가가 크게 잘못되었다는 뜻입니다. 예를 들어 rustc나 std, 또는 환경 변수에 의존하는 무언가를 컴파일하려는 경우가 이에 해당합니다. 드물게 이런 상황에서 실제로 rustc를 호출해야 한다면, x 명령에 -vvv를 추가하여 부트스트랩 심(shim)이 모든 env 변수를 출력하도록 할 수 있습니다.
마지막으로, 부트스트랩은 cc-rs 크레이트를 사용하며, 이 크레이트는 환경 변수를 통해 C 컴파일러와 C 플래그를 설정하는 자체 방식을 갖추고 있습니다.
빌드 명령의 stdout 설명
이 부분에서는 빌드 명령의 stdout이 실제로 어떻게 나타나는지 살펴보겠습니다(위 주제와 비슷하지만 더 자세하고 완전한 문서입니다). x build --dry-run 명령을 실행하면 빌드 출력은 다음과 비슷하게 나타납니다.
Building stage0 library artifacts (x86_64-unknown-linux-gnu -> x86_64-unknown-linux-gnu)
Copying stage0 library from stage0 (x86_64-unknown-linux-gnu -> x86_64-unknown-linux-gnu / x86_64-unknown-linux-gnu)
Building stage0 compiler artifacts (x86_64-unknown-linux-gnu -> x86_64-unknown-linux-gnu)
Copying stage0 rustc from stage0 (x86_64-unknown-linux-gnu -> x86_64-unknown-linux-gnu / x86_64-unknown-linux-gnu)
Assembling stage1 compiler (x86_64-unknown-linux-gnu)
Building stage1 library artifacts (x86_64-unknown-linux-gnu -> x86_64-unknown-linux-gnu)
Copying stage1 library from stage1 (x86_64-unknown-linux-gnu -> x86_64-unknown-linux-gnu / x86_64-unknown-linux-gnu)
Building stage1 tool rust-analyzer-proc-macro-srv (x86_64-unknown-linux-gnu)
Building rustdoc for stage1 (x86_64-unknown-linux-gnu)
stage0 {std,컴파일러} 산출물 빌드
이 단계들은 제공된(보통은 다운로드된) 컴파일러를 사용하여 로컬 Rust 소스를 우리가 사용할 수 있는 라이브러리로 컴파일합니다.
stage0 {std,rustc} 복사
이 단계는 라이브러리와 컴파일러 산출물을 cargo에서 stage0-sysroot/lib/rustlib/{target-triple}/lib로 복사합니다
stage1 컴파일러 조립
이 단계는 “stage0 … 산출물 빌드“에서 빌드한 라이브러리를 stage1 컴파일러의 lib/ 디렉터리로 복사합니다. 이들은 컴파일러 자체가 실행되는 데 사용하는 호스트 라이브러리입니다. 이들은 새 컴파일러가 생성하는 산출물에서는 실제로 사용되지 않습니다. 이 단계는 또한 우리가 생성한 rustc와 rustdoc 바이너리를 build/$HOST/stage/bin으로 복사합니다.
stage1/bin/rustc는 stage0(사전 컴파일된) 컴파일러와 std로 빌드된, 완전히 동작하는 컴파일러입니다. 트리 내(in-tree) 컴파일러와 std를 사용해 전부 소스에서 빌드된 컴파일러를 사용하려면 stage2 컴파일러를 빌드해야 하며, 이 컴파일러는 stage1(트리 내) 컴파일러와 std를 사용하여 컴파일됩니다.
Bootstrap의 동작 방식
Bootstrap의 핵심 개념은 빌드 Step이며, 이들은 Builder::ensure에 의해 서로 연결됩니다. Builder::ensure는 Step을 입력으로 받아, 해당 Step이 아직 실행되지 않은 경우에만 이를 실행합니다. Step을 좀 더 자세히 살펴봅시다.
Step 개요
Step은 어떤 산출물을 만들어 내는 과정에 관여하는 세분화된 작업들의 모음을 나타냅니다. Makefile의 규칙과 비슷한 것으로 생각할 수 있습니다. Step 트레이트는 다음과 같이 정의되어 있습니다:
pub trait Step: 'static + Clone + Debug + PartialEq + Eq + Hash {
type Output: Clone;
const DEFAULT: bool = false;
const ONLY_HOSTS: bool = false;
// Required methods
fn run(self, builder: &Builder<'_>) -> Self::Output;
fn should_run(run: ShouldRun<'_>) -> ShouldRun<'_>;
// Provided method
fn make_run(_run: RunConfig<'_>) { ... }
}
run은 실제 작업을 수행하는 함수입니다.Builder::ensure가run을 호출합니다.should_run은 명령줄 인터페이스로,x build foo와 같은 호출이 주어진Step을 실행해야 하는지를 결정합니다. 경로가 주어지지 않은 “기본” 상황에서는make_run이 직접 호출됩니다.make_run은 CLI를 통해 직접 요청된 항목에 대해서만 호출되며, 다른 단계의 의존성인 단계에 대해서는 호출되지 않습니다.
진입점
핵심 Bootstrap 코드에 도달하기 전에 몇 가지 사전 단계를 거칩니다:
- 셸 스크립트 또는
make:./x또는./x.ps1또는make - 편의용 래퍼 스크립트:
x.py src/bootstrap/bootstrap.pysrc/bootstrap/src/bin/main.rs
구현 세부 사항에 대한 더 구체적인 설명은 src/bootstrap/README.md를 참고하십시오.
Bootstrap에서 도구 작성하기
bootstrap에서 작성할 수 있는 도구에는 세 가지 유형이 있습니다.
-
Mode::ToolBootstrap트리 내 컴파일러의 어떤 것도 필요하지 않고 stage0
rustc로 실행할 수 있는 도구에 이 모드를 사용하십시오. 결과물은 “bootstrap-tools” 디렉터리에 배치됩니다. 이 모드는 타깃 라이브러리를 포함해 전적으로 stage0 컴파일러로 빌드되는 범용 도구를 위한 것이며, stage 0에서만 동작합니다. -
Mode::ToolStd로컬에서 빌드된 std에 의존하는 도구에 이 모드를 사용하십시오. 결과물은 “stageN-tools” 디렉터리에 들어갑니다. 이 모드는 거의 사용되지 않으며, 주로
libtest를 필요로 하는compiletest에 사용됩니다. -
Mode::ToolRustcPrivaterustc_private메커니즘을 사용하므로 로컬에서 빌드된rustc와 그 rlib 산출물에 의존하는 도구에 이 모드를 사용하십시오. 도구는rustc에 사용된 것과 동일한 컴파일러로 빌드되어 “stageN-tools” 디렉터리에 배치되어야 하므로, 이 모드는 다른 모드보다 더 복잡합니다.Mode::ToolRustcPrivate를 선택하면ToolBuild구현이 이를 자동으로 처리합니다. 특정 작업에 빌더의 컴파일러를 사용해야 한다면, 도구의Step이 반환하는ToolBuildResult에서 가져올 수 있습니다.
도구 유형과 관계없이 도구의 Step 구현에서 ToolBuildResult를 반환해야 하며, 그 안에서 ToolBuild를 사용해야 합니다.
부트스트랩 디버깅
부트스트랩을 디버깅(및 프로파일링)하는 주요 방법은 두 가지입니다. 첫 번째는 println 로깅을 통한 방법이고, 두 번째는 tracing 기능을 통한 방법입니다.
println 로깅
부트스트랩은 방대한 비구조화 로깅을 제공합니다. 대부분은 --verbose 플래그를 지정해야 출력됩니다(더 자세한 정보를 보려면 -vv를 전달하십시오).
실행된 Cargo 명령의 상세 출력과 그 밖의 자세한 로그를 보려면 부트스트랩을 호출할 때 -v 또는 -vv를 전달하십시오. 이 로그는 구조화되어 있지 않으며 양이 너무 많을 수 있다는 점에 유의하십시오.
$ ./x dist rustc --dry-run -vv
learning about cargo
running: RUSTC_BOOTSTRAP="1" "/home/jyn/src/rust2/build/x86_64-unknown-linux-gnu/stage0/bin/cargo" "metadata" "--format-version" "1" "--no-deps" "--manifest-path" "/home/jyn/src/rust2/Cargo.toml" (failure_mode=Exit) (created at src/bootstrap/src/core/metadata.rs:81:25, executed at src/bootstrap/src/core/metadata.rs:92:50)
running: RUSTC_BOOTSTRAP="1" "/home/jyn/src/rust2/build/x86_64-unknown-linux-gnu/stage0/bin/cargo" "metadata" "--format-version" "1" "--no-deps" "--manifest-path" "/home/jyn/src/rust2/library/Cargo.toml" (failure_mode=Exit) (created at src/bootstrap/src/core/metadata.rs:81:25, executed at src/bootstrap/src/core/metadata.rs:92:50)
...
부트스트랩의 tracing
부트스트랩에는 조건부 tracing 기능이 있으며, 이 기능은 다음을 제공합니다.
tracing이벤트와 스팬을 사용한 구조화된 로깅을 활성화합니다.- 실행된 단계와 명령의 계층 구조 및 소요 시간을 시각화하는 데 사용할 수 있는 Chrome 트레이스 파일을 생성합니다.
- 생성된
chrome-trace.json파일은 Chrome의chrome://tracing탭에서 열거나, 예를 들어 Perfetto를 사용하여 열 수 있습니다.
- 생성된
- 실행된 단계 간의 의존성을 시각화하는 GraphViz 그래프를 생성합니다.
- 생성된
step-graph-*.dot파일은 예를 들어 xdot으로 열어 단계 그래프를 시각화하거나,dot -Tsvg등을 사용하여 GraphViz 파일을 SVG 파일로 변환할 수 있습니다.
- 생성된
- 어떤 명령이 실행되었는지, 그 실행 중 몇 번이 캐시되었는지, 어떤 명령의 실행이 가장 느렸는지를 보여 주는 명령 실행 요약을 생성합니다.
- 생성된
command-stats.txt파일은 사람이 읽기 쉬운 간단한 형식으로 되어 있습니다.
- 생성된
구조화된 로그는 표준 오류 출력(stderr)에 기록되고, 나머지 출력은 <build-dir>/bootstrap-trace/<pid> 디렉터리의 파일에 저장됩니다. 편의를 위해 부트스트랩은 가장 최근에 생성된 트레이스 출력 디렉터리를 가리키는 심볼릭 링크도 <build-dir>/bootstrap-trace/latest에 생성합니다.
부트스트랩을
--dry-run으로 실행하면 트레이싱 출력 디렉터리가 달라질 수 있다는 점에 유의하십시오. 부트스트랩은 실행이 끝날 때 트레이싱 출력 파일이 저장된 경로를 항상 출력합니다.
tracing 출력 활성화
조건부 tracing 기능을 활성화하려면 BOOTSTRAP_TRACING 환경 변수를 설정하여 부트스트랩을 실행하십시오.
$ BOOTSTRAP_TRACING=trace ./x build library --stage 1
출력 예시1:
$ BOOTSTRAP_TRACING=trace ./x build library --stage 1 --dry-run
Building bootstrap
Finished `dev` profile [unoptimized] target(s) in 0.05s
15:56:52.477 INFO > tool::LibcxxVersionTool {target: x86_64-unknown-linux-gnu} (builder/mod.rs:1715)
15:56:52.575 INFO > compile::Assemble {target_compiler: Compiler { stage: 0, host: x86_64-unknown-linux-gnu, forced_compiler: false }} (builder/mod.rs:1715)
15:56:52.575 INFO > tool::Compiletest {compiler: Compiler { stage: 0, host: x86_64-unknown-linux-gnu, forced_compiler: false }, target: x86_64-unknown-linux-gnu} (builder/mod.rs:1715)
15:56:52.576 INFO > tool::ToolBuild {build_compiler: Compiler { stage: 0, host: x86_64-unknown-linux-gnu, forced_compiler: false }, target: x86_64-unknown-linux-gnu, tool: "compiletest", path: "src/tools/compiletest", mode: ToolBootstrap, source_type: InTree, extra_features: [], allow_features: "internal_output_capture", cargo_args: [], artifact_kind: Binary} (builder/mod.rs:1715)
15:56:52.576 INFO > builder::Libdir {compiler: Compiler { stage: 0, host: x86_64-unknown-linux-gnu, forced_compiler: false }, target: x86_64-unknown-linux-gnu} (builder/mod.rs:1715)
15:56:52.576 INFO > compile::Sysroot {compiler: Compiler { stage: 0, host: x86_64-unknown-linux-gnu, forced_compiler: false }, force_recompile: false} (builder/mod.rs:1715)
15:56:52.578 INFO > compile::Assemble {target_compiler: Compiler { stage: 0, host: x86_64-unknown-linux-gnu, forced_compiler: false }} (builder/mod.rs:1715)
15:56:52.578 INFO > tool::Compiletest {compiler: Compiler { stage: 0, host: x86_64-unknown-linux-gnu, forced_compiler: false }, target: x86_64-unknown-linux-gnu} (builder/mod.rs:1715)
15:56:52.578 INFO > tool::ToolBuild {build_compiler: Compiler { stage: 0, host: x86_64-unknown-linux-gnu, forced_compiler: false }, target: x86_64-unknown-linux-gnu, tool: "compiletest", path: "src/tools/compiletest", mode: ToolBootstrap, source_type: InTree, extra_features: [], allow_features: "internal_output_capture", cargo_args: [], artifact_kind: Binary} (builder/mod.rs:1715)
15:56:52.578 INFO > builder::Libdir {compiler: Compiler { stage: 0, host: x86_64-unknown-linux-gnu, forced_compiler: false }, target: x86_64-unknown-linux-gnu} (builder/mod.rs:1715)
15:56:52.578 INFO > compile::Sysroot {compiler: Compiler { stage: 0, host: x86_64-unknown-linux-gnu, forced_compiler: false }, force_recompile: false} (builder/mod.rs:1715)
Finished `release` profile [optimized] target(s) in 0.11s
Tracing/profiling output has been written to <src-root>/build/bootstrap-trace/latest
Build completed successfully in 0:00:00
트레이싱 출력 제어
환경 변수 BOOTSTRAP_TRACING은 tracing_subscriber 필터를 받습니다. BOOTSTRAP_TRACING=trace로 설정하면 모든 로그가 활성화되지만, 양이 너무 많을 수 있습니다. 따라서 필터를 사용하여 기록되는 데이터의 양을 줄일 수 있습니다.
원하는 트레이싱 로그의 종류를 제어하는 서로 독립적인 방법이 두 가지 있습니다.
debug나trace와 같이 로그 레벨을 지정할 수 있습니다.- 레벨을 선택하면 우선순위 레벨이 그와 같거나 더 높은 모든 이벤트/스팬이 표시됩니다.
- 로그 대상도 제어할 수 있습니다. 예를 들어
bootstrap,bootstrap::core::config또는CONFIG_HANDLING이나STEP같은 사용자 정의 대상을 지정할 수 있습니다.BOOTSTRAP_TRACING=trace출력은 상당히 장황할 수 있으므로, 사용자 정의 대상은 관심 있는 스팬의 종류를 제한하는 데 사용됩니다. 현재 다음 사용자 정의 대상을 사용할 수 있습니다.CONFIG_HANDLING: 설정 처리와 관련된 스팬을 표시합니다.STEP: 실행된 모든 단계를 표시합니다. 실행된 명령은info이벤트 레벨을 가집니다.COMMAND: 실행된 모든 명령을 표시합니다. 실행된 명령은trace이벤트 레벨을 가집니다.IO: 수행된 I/O 작업을 표시합니다. 실행된 명령은trace이벤트 레벨을 가집니다.- 현재 많은 I/O가 추적되지 않고 있다는 점에 유의하십시오.
물론 이들을 조합할 수도 있습니다(사용자 정의 대상 로그는 일반적으로 TRACE 로그 레벨로도 추가로 제한됩니다).
$ BOOTSTRAP_TRACING=CONFIG_HANDLING=trace,STEP=info,COMMAND=trace ./x build library --stage 1
BOOTSTRAP_TRACING으로 지정한 레벨은 Chrome 추적 파일에 기록될 스팬에도 영향을 준다는 점에 유의하십시오.
BOOTSTRAP_TRACING_SKIP_TIME=1 환경 변수를 전달하여 추적 출력에 타임스탬프가 포함되지 않도록 할 수도 있습니다. 이는 두 버전의 bootstrap 간에 실행된 단계를 비교하거나 diff할 때 유용합니다.
FIXME(#96176): compiler()와 compiler_for()에 대한 전용 추적
추가 대상인 COMPILER와 COMPILER_FOR는 builder.compiler()와 builder.compiler_for()가 무엇을 하는지 추적하는 데 도움을 주기 위해 사용됩니다. #96176 이슈가 해결되면 이 대상들은 제거되어야 합니다.
bootstrap에서 tracing 사용하기
tracing::* 매크로와 tracing::instrument proc-macro 속성은 모두 tracing 기능 뒤에 게이트되어야 합니다. 예시:
#[cfg(feature = "tracing")]
use tracing::instrument;
struct Foo;
impl Step for Foo {
type Output = ();
#[cfg_attr(feature = "tracing", instrument(level = "trace", name = "Foo::should_run", skip_all))]
fn should_run(run: ShouldRun<'_>) -> ShouldRun<'_> {
trace!(?run, "entered Foo::should_run");
todo!()
}
fn run(self, builder: &Builder<'_>) -> Self::Output {
trace!(?run, "entered Foo::run");
todo!()
}
}
#[instrument]의 경우 다음을 권장합니다.
- 세밀한 추적을 위해
trace레벨 뒤에 게이트하고, 핵심 함수라면debug레벨 뒤에 게이트하는 것도 고려하십시오. - 예를 들어 서로 다른 단계의
run을 구별할 수 있도록name = ".."을 통해 계측 이름을 명시적으로 지정하십시오. - 추적으로 인해 동작이 달라지지 않도록 주의하십시오. 예를 들어 추적 인프라가 활성화된 경우에만 추가적인 것을 빌드해서는 안 됩니다.
rust-analyzer 통합은 가능합니까?
안타깝게도 bootstrap은 rust-analyzer.linkedProjects이므로, https://github.com/rust-lang/rust-analyzer/issues/8521에 설명된 대로 지원이 부족하여 관련 자동 완성을 얻기 위해 tracing 기능을 활성화한 상태로 r-a에게 bootstrap 자체를 검사하거나 빌드하도록 요청할 수 없습니다.
-
이 출력은 언제든지 변경될 수 있습니다. ↩
컴파일러 의존성에서의 cfg(bootstrap)
Rust 컴파일러가 사용하는 일부 외부 크레이트는 컴파일러 자체와 순환 의존성이 생길 수 있습니다. 컴파일러를 빌드하려면 업데이트된 크레이트가 필요하지만, 그 크레이트에는 업데이트된 컴파일러가 필요합니다. 이 페이지에서는 #[cfg(bootstrap)]을 사용하여 이 순환을 끊는 방법을 설명합니다.
#[cfg(bootstrap)] 활성화하기
일반적으로 외부 크레이트에서 #[cfg(bootstrap)]을 사용하면 경고가 발생합니다:
warning: unexpected `cfg` condition name: `bootstrap`
--> src/main.rs:1:7
|
1 | #[cfg(bootstrap)]
| ^^^^^^^^^
|
= help: expected names are: `docsrs`, `feature`, and `test` and 31 more
= help: consider using a Cargo feature instead
= help: or consider adding in `Cargo.toml` the `check-cfg` lint config for the lint:
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(bootstrap)'] }
= help: or consider adding `println!("cargo::rustc-check-cfg=cfg(bootstrap)");` to the top of the `build.rs`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: `#[warn(unexpected_cfgs)]` on by default
이 경고는 프로젝트의 Cargo.toml에 다음 줄을 추가하여 없앨 수 있습니다:
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(bootstrap)'] }
이제 컴파일러에서와 마찬가지로 크레이트에서도 #[cfg(bootstrap)]을 사용할 수 있습니다. 부트스트랩 컴파일러를 사용할 때는 #[cfg(bootstrap)]이 지정된 코드가 컴파일되고, 그렇지 않을 때는 #[cfg(not(bootstrap))]이 지정된 코드가 컴파일됩니다.
업데이트 절차
구체적인 예로, #[naked] 속성을 unsafe 속성으로 바꾸면서 compiler-builtins 크레이트와 순환 의존성이 생겼던 변경을 살펴보겠습니다.
1단계: 컴파일러에서 새 동작 허용하기 (#139797)
이 예에서는 오류를 비활성화하여 이전 동작과 새 동작을 동시에 허용할 수 있습니다.
2단계: 크레이트 업데이트하기 (#821)
이제 크레이트에서 이전 동작을 사용하려면 #[cfg(bootstrap)]을, 새 동작을 사용하려면 #[cfg(not(bootstrap))]을 사용하십시오.
3단계: 컴파일러가 사용하는 크레이트 버전 업데이트하기 (#139934)
compiler-builtins의 경우에는 버전을 올리는 것이었으며, 다른 경우에는 git 서브모듈 업데이트일 수 있습니다.
4단계: 컴파일러에서 이전 동작 제거하기 (#139753)
이제 업데이트된 크레이트를 사용할 수 있습니다. 이 예에서는 그 결과 이전 동작을 제거할 수 있게 되었습니다.
컴파일러의 고수준 아키텍처
이 가이드의 나머지 부분에서는 컴파일러가 어떻게 동작하는지 설명합니다. 컴파일러의 고수준 구조부터 컴파일의 각 단계가 어떻게 동작하는지까지 모든 내용을 다룹니다. 이 부분은 컴파일의 전체 과정에 관심이 있는 독자와 기여하고 싶은 특정 시스템에 대해 배우고자 하는 독자 모두가 쉽게 읽을 수 있도록 쓰였습니다. 불분명한 점이 있다면 rustc-dev-guide 저장소에 이슈를 등록하거나, 1부의 이 장에서 자세히 설명한 대로 컴파일러 팀에 문의하십시오.
이번 부에서는 컴파일러의 고수준 아키텍처를 살펴봅니다. 특히 컴파일러 전체에 영향을 미치는 세 가지 포괄적인 설계 선택인 쿼리 시스템, 증분 컴파일, 인터닝을 살펴봅니다.
컴파일러 개요
이 장에서는 프로그램을 컴파일하는 전체 과정, 즉 모든 요소가 어떻게 맞물려 동작하는지를 다룹니다.
Rust 컴파일러는 두 가지 면에서 특별합니다. 다른 컴파일러가 하지 않는 작업(예: 빌림 검사)을 코드에 수행하며, 관례에서 벗어난 구현 방식(예: 쿼리)을 많이 택하고 있습니다. 이 장에서는 이 두 가지를 차례로 다루고, 가이드의 나머지 부분에서는 각 구성 요소를 더 자세히 살펴보겠습니다.
컴파일러가 코드에 수행하는 작업
먼저 컴파일러가 코드에 어떤 작업을 수행하는지 살펴봅시다. 당분간은 필요한 경우를 제외하고 컴파일러가 이 단계들을 어떻게 구현하는지는 언급하지 않겠습니다.
호출
컴파일은 사용자가 Rust 소스 프로그램을 텍스트로 작성하고 그 프로그램에 대해 rustc 컴파일러를 호출할 때 시작됩니다. 컴파일러가 수행해야 하는 작업은 명령줄 옵션으로 정의됩니다. 예를 들어 nightly 기능(-Z 플래그)을 활성화하거나, check 전용 빌드를 수행하거나, 실행 가능한 기계어 코드 대신 LLVM 중간 표현(LLVM-IR)을 출력할 수 있습니다. rustc 실행 파일은 cargo를 통해 간접적으로 호출될 수도 있습니다.
명령줄 인자 파싱은 rustc_driver에서 이루어집니다. 이 크레이트는 사용자가 요청한 컴파일 구성을 정의하고, 이를 rustc_interface::Config의 형태로 나머지 컴파일 과정에 전달합니다.
렉싱과 파싱
가공되지 않은 Rust 소스 텍스트는 rustc_lexer에 있는 저수준 렉서가 분석합니다. 이 단계에서 소스 텍스트는 토큰이라고 부르는 원자적 소스 코드 단위의 스트림으로 변환됩니다. lexer는 유니코드 문자 인코딩을 지원합니다.
토큰 스트림은 컴파일 과정의 다음 단계를 준비하기 위해 rustc_parse에 있는 더 높은 수준의 렉서를 거칩니다. 이 단계에서는 Lexer struct를 사용하여 일련의 검증을 수행하고 문자열을 인턴된 심볼로 변환합니다(인터닝은 뒤에서 다룹니다). 문자열 인터닝은 서로 다른 문자열 값마다 불변 사본을 하나만 저장하는 방식입니다.
렉서는 인터페이스가 작으며 rustc의 진단 인프라에 직접 의존하지 않습니다. 그 대신 진단을 일반 데이터로 제공하며, 이 데이터는 rustc_parse::lexer에서 실제 진단으로 출력됩니다. lexer는 IDE와 절차적 매크로(때로는 “proc-macros“라고도 합니다) 모두를 위해 원본 정보를 손실 없이 보존합니다.
파서는 lexer에서 받은 토큰 스트림을 추상 구문 트리(AST)로 변환합니다. 파서는 구문 분석에 재귀 하향(top-down) 방식을 사용합니다. parser의 크레이트 진입점은 rustc_parse::parser::Parser에 있는 Parser::parse_crate_mod 메서드와 Parser::parse_mod 메서드입니다. 외부 모듈 파싱의 진입점은 rustc_expand::module::parse_external_mod입니다. 그리고 매크로 parser의 진입점은 Parser::parse_nonterminal입니다.
파싱은 bump, check, eat, expect, look_ahead를 비롯한 여러 parser 유틸리티 메서드를 사용하여 수행됩니다.
파싱은 의미론적 구성 요소별로 구성되어 있습니다. 개별 parse_* 메서드는 rustc_parse 디렉터리에서 찾을 수 있습니다. 소스 파일 이름은 구성 요소의 이름을 따릅니다. 예를 들어, parser에는 다음 파일들이 있습니다.
이 명명 방식은 여러 컴파일러 단계에 걸쳐 사용됩니다. 파싱, 로워링, 타입 검사, 타입이 지정된 고수준 중간 표현(THIR) 로워링, 중간 수준 중간 표현(MIR) 빌드 소스 전반에 걸쳐 같은 이름의 파일이나 디렉터리를 찾을 수 있습니다.
매크로 확장, AST 검증, 이름 해석, 초기 린트도 렉싱 및 파싱 단계에서 이루어집니다.
파서는 rustc_ast::ast::{Crate, Expr, Pat, …} AST 노드를 반환하며, 오류 처리에는 표준 Diag API가 사용됩니다. 일반적으로 Rust 컴파일러는 Rust 문법의 상위 집합을 파싱하여 오류로부터 복구를 시도하는 동시에 오류 타입도 내보냅니다.
AST 로워링
다음으로 AST는 AST를 컴파일러가 다루기 더 쉽게 표현한 고수준 중간 표현(HIR)으로 변환됩니다. 이 과정을 “로워링“이라고 하며, 루프나 async fn 같은 것에 대한 많은 디슈가링(축약되거나 생략된 구문 구조를 확장하고 정형화하는 것)이 포함됩니다.
그런 다음 HIR를 사용하여 타입 추론(표현식의 타입을 자동으로 알아내는 과정), 트레이트 해결(trait에 대한 각 참조를 impl과 짝짓는 과정), 타입 검사를 수행합니다. 타입 검사는 사용자가 작성한 내용을 나타내는 HIR의 타입(hir::Ty)을 컴파일러가 사용하는 내부 표현(Ty<'tcx>)으로 변환하는 과정입니다. 이 정보가 프로그램에서 사용되는 타입의 타입 안전성, 정확성, 일관성을 검증하는 데 사용되기 때문에 타입 검사라고 부릅니다.
MIR 로워링
HIR는 THIR(패턴 및 완전성 검사에 사용되는, 디슈가링이 한층 더 진행된 HIR)를 구성한 뒤 이를 MIR로 변환하는 방식으로 MIR(빌림 검사에 사용됨)로 한 단계 더 로어링됩니다.
MIR은 제네릭하며 이후의 코드 생성과 컴파일 속도를 향상시키기 때문에 MIR에서 많은 최적화를 수행합니다. 일부 최적화는 LLVM-IR 수준보다 MIR 수준에서 수행하는 것이 더 쉽습니다. 예를 들어, LLVM은 simplify_try MIR 최적화가 찾는 패턴을 최적화하지 못하는 것으로 보입니다.
Rust 코드는 코드 생성 중에 단형화되기도 하는데, 이는 타입 매개변수를 구체적인 타입으로 대체하여 모든 제네릭 코드의 사본을 만드는 것을 의미합니다. 이를 위해서는 어떤 구체적인 타입에 대해 코드를 생성해야 하는지 목록을 수집해야 합니다. 이를 단형화 수집이라고 하며, MIR 수준에서 이루어집니다.
코드 생성
이어서 간단히 코드 생성 또는 codegen이라고 부르는 단계를 시작합니다. 코드 생성 단계는 소스의 고수준 표현을 실행 가능한 바이너리로 바꾸는 단계입니다. rustc는 코드 생성에 LLVM을 사용하므로, 첫 번째 단계는 MIR을 LLVM-IR로 변환하는 것입니다. 바로 이 단계에서 MIR이 실제로 단형화(monomorphize)됩니다. LLVM-IR은 LLVM에 전달되고, LLVM은 이를 대상으로 훨씬 더 많은 최적화를 수행한 뒤 기계어 코드를 내보냅니다. 기계어 코드는 기본적으로 저수준 타입과 어노테이션이 추가된 어셈블리 코드입니다(예: ELF 오브젝트나 WASM). 그런 다음 여러 라이브러리와 바이너리를 함께 링크하여 최종 바이너리를 만듭니다.
컴파일러가 이를 수행하는 방법
지금까지 컴파일러가 여러분의 코드에 무엇을 하는지 개략적으로 살펴보았으니, 이제 컴파일러가 이 모든 일을 어떻게 하는지 개략적으로 살펴봅시다. 컴파일러가 충족하거나 최적화해야 하는 제약 조건과 상충하는 목표는 매우 많습니다. 예를 들면 다음과 같습니다.
- 컴파일 속도: 프로그램을 얼마나 빠르게 컴파일합니까? 컴파일 타임 분석이 많아지거나 정교해질수록 컴파일이 느려지는 경우가 많습니다.
- 또한 증분 컴파일을 지원하려 하므로 이 점도 고려해야 합니다. 사용자가 프로그램을 수정했을 때 어떤 작업을 다시 해야 하고 어떤 작업을 재사용할 수 있는지 어떻게 추적할 수 있겠습니까?
- 또한 증분 캐시에 너무 많은 것을 저장할 수는 없습니다. 디스크에서 불러오는 데 시간이 오래 걸리고 사용자 시스템의 공간을 많이 차지할 수 있기 때문입니다…
- 또한 증분 컴파일을 지원하려 하므로 이 점도 고려해야 합니다. 사용자가 프로그램을 수정했을 때 어떤 작업을 다시 해야 하고 어떤 작업을 재사용할 수 있는지 어떻게 추적할 수 있겠습니까?
- 컴파일러 메모리 사용량: 프로그램을 컴파일하는 동안 필요 이상으로 메모리를 사용하고 싶지 않습니다.
- 프로그램 속도: 컴파일된 프로그램은 얼마나 빠릅니까? 컴파일 타임 분석이 많아지거나 정교해질수록 컴파일러가 더 나은 최적화를 수행할 수 있는 경우가 많습니다.
- 프로그램 크기: 컴파일된 바이너리는 얼마나 큽니까? 앞의 항목과 비슷합니다.
- 컴파일러 컴파일 속도: 컴파일러 자체를 컴파일하는 데 얼마나 걸립니까? 이는 기여자와 컴파일러 유지 보수에 영향을 줍니다.
- 구현 복잡도: 컴파일러를 만드는 일은 개인이나 집단이 할 수 있는 가장 어려운 일 중 하나이며, Rust는 그다지 단순한 언어가 아닙니다. 그렇다면 컴파일러의 코드베이스를 어떻게 관리 가능한 상태로 유지할 수 있겠습니까?
- 컴파일러 정확성: 컴파일러가 만든 바이너리는 입력 프로그램이 명시한 대로 동작해야 하며, 끊임없이 일어나는 엄청난 양의 변경에도 불구하고 계속 그렇게 동작해야 합니다.
- 통합: 여러 다른 도구(예:
cargo,clippy,Miri)가 다양한 방식으로 컴파일러를 사용해야 하며, 이를 지원해야 합니다. - 컴파일러 안정성: 컴파일러는 stable 채널에서 크래시하거나 비정상적으로 실패해서는 안 됩니다.
- Rust 안정성: 컴파일러는 구현에 항상 많은 변경이 일어나더라도 이전에 컴파일되던 프로그램을 깨뜨리지 않음으로써 Rust의 안정성 보장을 지켜야 합니다.
- 다른 도구의 한계:
rustc는 백엔드에서 LLVM을 사용하는데, LLVM에는 우리가 활용하는 강점도 있고 우회해야 하는 측면도 있습니다.
그러니 이 가이드의 나머지 부분을 계속 읽어 나가면서 이러한 사항을 염두에 두십시오. 이러한 사항은 우리가 내리는 결정에 영향을 주는 경우가 많습니다.
중간 표현
대부분의 컴파일러와 마찬가지로 rustc도 계산을 쉽게 하기 위해 몇 가지 중간 표현(IR)을 사용합니다. 일반적으로 소스 코드를 직접 다루는 것은 매우 불편하고 오류가 생기기 쉽습니다. 소스 코드는 모호하지 않으면서도 사람이 다루기 쉽도록 설계되어 있지만, 이를테면 타입 검사 같은 작업을 하기에는 덜 편리합니다.
대신 rustc를 포함한 대부분의 컴파일러는 소스 코드에서 분석하기 쉬운 일종의 IR을 빌드합니다. rustc에는 여러 IR이 있으며, 각각 서로 다른 목적에 맞게 최적화되어 있습니다:
- 토큰 스트림: 렉서는 소스 코드로부터 직접 토큰 스트림을 생성합니다. 이 토큰 스트림은 원시 텍스트보다 파서가 다루기 더 쉽습니다.
- 추상 구문 트리(
AST): 추상 구문 트리는 렉서가 생성한 토큰 스트림으로부터 만들어집니다. 추상 구문 트리는 사용자가 작성한 내용을 거의 그대로 나타냅니다. 추상 구문 트리는 몇 가지 구문상의 온전성 검사(예: 사용자가 타입을 작성한 위치에 실제로 타입이 와야 하는지 확인하는 것)를 하는 데 도움이 됩니다. - 고수준 IR(HIR): 일종의 디슈가링된
AST입니다. 구문적으로는 여전히 사용자가 작성한 내용에 가깝지만, 생략된 일부 라이프타임 등 몇 가지 암묵적인 요소를 포함합니다. 이 IR은 타입 검사에 적합합니다. - 타입이 지정된
HIR(THIR) 이전 명칭은 고수준 추상 IR(HAIR):HIR과 MIR 사이의 중간 단계입니다.HIR과 비슷하지만, 타입이 완전히 지정되어 있고 디슈가링이 조금 더 진행되어 있습니다(예: 메서드 호출과 암묵적 역참조가 완전히 명시적으로 드러납니다). 그 결과 HIR보다THIR에서MIR로 로워링하는 것이 더 쉽습니다. - 중간 수준 IR(
MIR): 이 IR은 기본적으로 제어 흐름 그래프(CFG)입니다. CFG는 프로그램의 기본 블록과 그 사이에서 제어 흐름이 어떻게 이동할 수 있는지를 보여 주는 일종의 다이어그램입니다. 마찬가지로MIR에도 간단한 타입이 지정된 문장(예: 대입, 간단한 계산 등)을 담은 여러 기본 블록과, 다른 기본 블록으로 이어지는 제어 흐름 간선(예: 호출, 값 드롭)이 있습니다.MIR은 빌림 검사와, 초기화되지 않은 값 검사 같은 그 밖의 중요한 데이터 흐름 기반 검사에 사용됩니다. 또한 일련의 최적화와 (Miri를 통한) 상수 평가에도 사용됩니다.MIR은 아직 제네릭 상태이므로, 이 단계에서는 단형화 이후보다 많은 분석을 더 효율적으로 수행할 수 있습니다. LLVM-IR: LLVM 컴파일러에 입력되는 모든 것의 표준 형식입니다.LLVM-IR은 많은 주석이 달린 일종의 타입 있는 어셈블리 언어입니다. LLVM을 사용하는 모든 컴파일러가 사용하는 표준 형식입니다(예: clang C 컴파일러도LLVM-IR을 출력합니다).LLVM-IR은 다른 컴파일러가 쉽게 생성할 수 있으면서도, LLVM이 여러 최적화를 수행할 수 있을 만큼 풍부하도록 설계되었습니다.
또 하나 주목할 점은 컴파일러의 많은 값이 인터닝된다는 것입니다. 이는 값을 아레나라는 특별한 할당자에 할당하는 성능 및 메모리 최적화 기법입니다. 그런 다음 아레나에 할당된 값에 대한 참조를 주고받습니다. 이를 통해 동일한 값(예: 프로그램의 타입)이 한 번만 할당되도록 보장할 수 있으며, 포인터를 비교하는 것만으로 값을 저렴하게 비교할 수 있습니다. 많은 중간 표현이 인터닝됩니다.
쿼리
첫 번째 주요 구현상의 선택은 Rust가 컴파일러에서 쿼리 시스템을 사용한다는 점입니다. Rust 컴파일러는 코드를 대상으로 순차적으로 실행되는 일련의 패스로 구성되어 있지 않습니다. Rust 컴파일러가 이렇게 하는 것은 증분 컴파일을 가능하게 하기 위해서입니다. 즉, 사용자가 프로그램을 변경하고 다시 컴파일할 때, 새 바이너리를 출력하는 데 드는 중복 작업을 최대한 줄이고자 하는 것입니다.
rustc에서는 위의 모든 주요 단계가 서로를 호출하는 여러 쿼리로 구성되어 있습니다. 예를 들어, 어떤 대상의 타입을 요청하는 쿼리가 있고, 함수의 최적화된 MIR을 요청하는 쿼리도 있습니다. 이러한 쿼리는 서로를 호출할 수 있으며, 모두 쿼리 시스템을 통해 추적됩니다. 쿼리 결과는 디스크에 캐시되므로, 컴파일러는 지난 컴파일 이후 어떤 쿼리의 결과가 바뀌었는지 파악하여 해당 쿼리만 다시 수행할 수 있습니다. 증분 컴파일은 이러한 방식으로 동작합니다.
원칙적으로 쿼리화된 단계에서는 위의 각 작업을 항목마다 개별적으로 수행합니다. 예를 들어, 어떤 함수의 HIR을 가져와 쿼리를 사용해 그 HIR에 대한 LLVM-IR을 요청합니다. 이는 최적화된 MIR의 생성을 유발하고, 최적화된 MIR의 생성은 빌림 검사기를 구동하며, 빌림 검사기는 다시 MIR의 생성을 유발하는 식으로 이어집니다.
… 다만 이는 지나치게 단순화한 설명입니다. 실제로 일부 쿼리는 디스크에 캐시되지 않으며, 컴파일러의 일부(예: 빌림 검사기)는 정확성을 위해 코드가 죽은 코드이더라도 어차피 모든 코드에 대해 실행되어야 합니다. 예를 들어, 현재 mir_borrowck 쿼리는 먼저 크레이트의 모든 함수에 대해 실행됩니다. 그런 다음 코드 생성 백엔드가 collect_and_partition_mono_items 쿼리를 호출하는데, 이 쿼리는 먼저 도달 가능한 모든 함수에 대해 optimized_mir을 재귀적으로 요청하고, 이는 다시 해당 함수에 대해 mir_borrowck를 실행한 뒤 코드 생성 단위를 만듭니다. 도달할 수 없는 함수에서도 오류가 여전히 보고되도록 하려면 이러한 분리는 계속 유지되어야 합니다.
게다가 컴파일러는 원래 쿼리 시스템을 사용하도록 만들어지지 않았습니다. 쿼리 시스템은 나중에 컴파일러에 덧붙여진 것이므로, 컴파일러의 일부는 아직 쿼리화되지 않았습니다. 또한 LLVM은 우리 코드가 아니므로 역시 쿼리화되어 있지 않습니다. 최종적으로는 이전 절에 나열된 모든 단계를 쿼리화할 계획이지만, 2022년 11월 현재 HIR와 LLVM-IR 사이의 단계만 쿼리화되어 있습니다. 즉, 렉싱, 파싱, 이름 해석, 매크로 확장은 전체 프로그램에 대해 한꺼번에 수행됩니다.
여기서 한 가지 더 언급할 것은 매우 중요한 “타이핑 컨텍스트“인 TyCtxt입니다. 이는 모든 것의 중심에 있는 거대한 구조체입니다. (이 이름은 대체로 역사적인 이유로 붙은 것입니다. 이것은 타입 이론에서 말하는 Γ나 Δ와 같은 의미의 “타이핑 컨텍스트“가 아닙니다. 소스 코드에서 구조체 이름이 그렇게 되어 있기 때문에 이 이름을 유지하고 있습니다.) 모든 쿼리는 TyCtxt 타입의 메서드로 정의되며, 메모리 내 쿼리 캐시도 이곳에 저장됩니다. 코드에는 보통 타이핑 컨텍스트에 대한 핸들인 tcx라는 변수가 있습니다. 'tcx라는 이름의 라이프타임도 보게 될 텐데, 이는 어떤 대상이 TyCtxt의 라이프타임에 묶여 있다는 뜻입니다(대개 그곳에 저장되거나 인터닝되어 있습니다).
컴파일러의 쿼리에 대한 자세한 내용은 쿼리 장을 참고하십시오.
ty::Ty
Rust에서 타입은 매우 중요하며, 여러 컴파일러 분석의 핵심을 이룹니다. (사용자 프로그램의) 타입을 표현하는 (컴파일러 내의) 주요 타입은 rustc_middle::ty::Ty입니다. 이는 매우 중요하기 때문에 ty::Ty를 다루는 장이 따로 있지만, 지금은 이것이 존재하며 rustc가 타입을 표현하는 방식이라는 점만 언급해 두겠습니다!
또한 rustc_middle::ty 모듈이 앞서 언급한 TyCtxt 구조체를 정의한다는 점도 참고하십시오.
병렬성
컴파일러 성능은 우리가 개선하고자 하는(그리고 늘 개선하기 위해 노력하고 있는) 문제입니다. 그 한 측면은 rustc 자체를 병렬화하는 것입니다.
현재 rustc에서 기본적으로 병렬로 동작하는 부분은 코드 생성 하나뿐입니다.
그러나 컴파일러의 나머지 부분은 아직 병렬화되지 않았습니다. 이를 위해 많은 노력을 기울여 왔지만, 이는 대체로 어려운 문제입니다. 현재의 접근 방식은 RefCell을 Mutex로 바꾸는 것, 즉 스레드 안전한 내부 가변성으로 전환하는 것입니다. 그러나 락 경합, 동시성 환경에서의 쿼리 시스템 불변 조건 유지, 코드베이스의 복잡성 등 여전히 해결해야 할 과제가 남아 있습니다. bootstrap.toml에서 병렬 컴파일을 활성화하면 현재 진행 중인 작업을 직접 사용해 볼 수 있습니다. 아직 초기 단계이지만, 이미 몇 가지 유망한 성능 향상이 나타나고 있습니다.
부트스트래핑
rustc 자체는 Rust로 작성되어 있습니다. 그렇다면 컴파일러는 어떻게 컴파일합니까? 이전 버전의 컴파일러를 사용하여 새 버전의 컴파일러를 컴파일합니다. 이를 부트스트래핑이라고 합니다.
부트스트래핑에는 흥미로운 함의가 많습니다. 예를 들어, 이는 Rust의 주요 사용자 중 하나가 Rust 컴파일러라는 뜻이므로, 우리는 우리 자신의 소프트웨어를 끊임없이 테스트하고 있는 셈입니다(“자기 개밥 먹기”).
부트스트래핑에 대한 자세한 내용은 가이드의 부트스트래핑 절을 참고하십시오.
참고 자료
- 명령줄 파싱
- 가이드: Rustc 드라이버와 인터페이스
- 드라이버 정의:
rustc_driver - 주요 진입점:
rustc_session::config::build_session_options
- 어휘 분석: 사용자 프로그램을 토큰 스트림으로 렉싱
- 가이드: 렉싱과 파싱
- 렉서 정의:
rustc_lexer - 주요 진입점:
rustc_lexer::Cursor::advance_token
- 파싱: 토큰 스트림을 추상 구문 트리(AST)로 파싱
- 고수준 중간 표현(HIR)
- 가이드: HIR
- 가이드: HIR의 식별자
- 가이드:
HIR맵 - 가이드:
AST를HIR로 로워링하기 - 코드의
HIR표현을 확인하는 방법:cargo rustc -- -Z unpretty=hir-tree - Rustc
HIR정의:rustc_hir - 주 진입점: TODO
- 후기 린트: TODO
- 타입 추론
- 가이드: 타입 추론
- 가이드: ty 모듈: 타입 표현하기 (의미론)
- 주 진입점(타입 추론):
InferCtxtBuilder::enter - 주 진입점(본문 타입 검사):
typeck쿼리- 이 두 함수는 서로 분리할 수 없습니다.
- 중간 수준 중간 표현(MIR)
- 가이드:
MIR(중간 수준 IR) - 정의:
rustc_middle/src/mir - MIR을 다루는 소스의 정의:
rustc_mir_build,rustc_mir_dataflow,rustc_mir_transform
- 가이드:
- 빌림 검사기
- 가이드: MIR 빌림 검사
- 정의:
rustc_borrowck - 주 진입점:
mir_borrowck쿼리
MIR최적화- 가이드: MIR 최적화
- 정의:
rustc_mir_transform - 주 진입점:
optimized_mir쿼리
- 코드 생성
- 가이드: 코드 생성
- LLVM으로
LLVM-IR에서 기계어 코드 생성하기 - TODO: 참고 자료? - 주 진입점:
rustc_codegen_ssa::base::codegen_crate- 이 함수는 하나의 코드 생성 단위에 대해 단형화를 수행하고
LLVM-IR을 생성합니다. 그런 다음 LLVM을 실행할 백그라운드 스레드를 시작하며, 이 스레드는 나중에 반드시 join해야 합니다. - 단형화는
FunctionCx::monomorphize와rustc_codegen_ssa::base::codegen_instance를 통해 지연 방식으로 이루어집니다.
- 이 함수는 하나의 코드 생성 단위에 대해 단형화를 수행하고
컴파일러 소스의 고수준 개요
컴파일러가 무엇을 하는지 살펴보았으니, 이제 rustc 소스 코드가 있는 rust-lang/rust 저장소의 구조를 살펴봅시다.
이 장을 읽기 전에 컴파일러의 동작 방식을 소개하는 “컴파일러 개요” 장을 먼저 읽으면 도움이 될 수 있습니다.
워크스페이스 구조
rust-lang/rust 저장소는 컴파일러, 표준 라이브러리(core, alloc, std, proc_macro, etc), rustdoc뿐 아니라 빌드 시스템과 완전한 Rust 배포판을 빌드하기 위한 여러 도구 및 서브모듈을 포함하는 하나의 커다란 cargo 워크스페이스로 구성되어 있습니다.
저장소는 세 개의 주요 디렉터리로 구성되어 있습니다.
-
compiler/에는rustc의 소스 코드가 들어 있습니다. 이 디렉터리는 함께 컴파일러를 이루는 여러 크레이트로 구성되어 있습니다. -
library/에는 표준 라이브러리(core,alloc,std,proc_macro,test)와 Rust 런타임(backtrace,rtstartup,lang_start)이 들어 있습니다. -
tests/에는 컴파일러 테스트가 들어 있습니다. -
src/에는rustdoc,clippy,cargo, 빌드 시스템, 언어 문서 등의 소스 코드가 들어 있습니다.
컴파일러
컴파일러는 여러 compiler/ 크레이트에 구현되어 있습니다. compiler/ 크레이트의 이름은 모두 rustc_*로 시작합니다. 이들은 아주 작은 것부터 매우 큰 것까지 크기가 다양한, 서로 의존하는 약 50개의 크레이트 모음입니다. 실제 바이너리(즉, main 함수)인 rustc 크레이트도 있습니다. 이 크레이트는 다른 크레이트에 있는 컴파일의 여러 부분을 구동하는 rustc_driver 크레이트를 호출하는 것 외에는 실제로 아무 일도 하지 않습니다.
이 크레이트들의 의존성 순서는 복잡하지만, 대략 다음과 같습니다.
rustc(바이너리)는rustc_driver::main을 호출합니다.rustc_driver는 다른 많은 크레이트에 의존하지만, 그중 주된 것은rustc_interface입니다.rustc_interface는 다른 컴파일러 크레이트 대부분에 의존합니다. 이것은 전체 컴파일 과정을 구동하기 위한 상당히 제네릭한 인터페이스입니다.- 다른
rustc_*크레이트 대부분은rustc_middle에 의존하며, 이 크레이트는 컴파일러의 핵심 자료 구조를 다수 정의합니다. rustc_middle과 다른 크레이트 대부분은 컴파일러의 초기 부분(예: 파서), 기본 자료 구조(예:Span), 또는 오류 보고를 담당하는 소수의 크레이트에 의존합니다.rustc_data_structures,rustc_span,rustc_errors등이 이에 해당합니다.
다른 Rust 패키지와 마찬가지로 cargo tree를 실행하면 정확한 의존성을 확인할 수 있습니다.
cargo tree --package rustc_driver
마지막으로, src/llvm-project는 우리가 포크한 LLVM의 서브모듈입니다. 부트스트래핑 중에 LLVM이 빌드되며, compiler/rustc_llvm 크레이트에는 (C++로 작성된) LLVM을 감싸는 Rust 래퍼가 들어 있어 컴파일러가 LLVM과 상호 작용할 수 있습니다.
이 책의 대부분은 컴파일러에 관한 내용이므로, 여기서는 이 크레이트들에 대해 더 설명하지 않겠습니다.
큰 그림
컴파일러의 의존성 구조는 두 가지 주요 요인의 영향을 받습니다.
- 구성. 컴파일러는 거대한 코드베이스이므로, 하나의 크레이트로 만든다면 감당할 수 없을 만큼 커질 것입니다. 의존성 구조는 부분적으로 컴파일러의 코드 구조를 반영합니다.
- 컴파일 시간. 컴파일러를 여러 크레이트로 나누면 cargo를 사용한 증분/병렬 컴파일의 이점을 더 잘 활용할 수 있습니다. 특히 크레이트 하나를 변경했을 때 다시 빌드해야 하는 크레이트 수를 줄이기 위해 크레이트 간 의존성을 가능한 한 적게 유지하려고 합니다.
의존성 트리의 가장 아래에는 컴파일러 전체에서 사용하는 몇 개의 크레이트가 있습니다(예: rustc_span). 컴파일 과정의 가장 초기 단계(예: 파싱과 추상 구문 트리(AST))는 이 크레이트들에만 의존합니다.
AST가 구성되고 다른 초기 분석이 끝나면 컴파일러의 쿼리 시스템이 설정됩니다. 쿼리 시스템은 함수 포인터를 사용하는 영리한 방식으로 설정됩니다. 이 방식 덕분에 크레이트 간 의존성을 끊을 수 있어 더 많은 병렬 컴파일이 가능합니다. 쿼리 시스템은 rustc_middle에 정의되어 있으므로 컴파일러의 이후 부분은 거의 모두 이 크레이트에 의존합니다. 이 크레이트는 매우 커서 컴파일 시간이 오래 걸립니다. 이 크레이트에서 코드를 분리해 내려는 노력이 몇 차례 있었으며, 성과는 제각각이었습니다. 또 다른 부작용은 관련된 기능이 때때로 여러 크레이트에 흩어진다는 점입니다. 예를 들어 린트 기능은 크레이트의 초기 부분, rustc_lint, rustc_middle 및 기타 여러 곳에 걸쳐 있습니다.
이상적으로는 증분 컴파일과 병렬 컴파일이 컴파일 시간을 적정 수준으로 유지해 주면서, 크레이트 수는 더 적고 각 크레이트의 응집도는 더 높은 것이 바람직합니다. 하지만 증분 컴파일과 병렬 컴파일이 아직 그 정도로 발전하지 못했기 때문에, 지금까지는 여러 크레이트로 나누는 것을 해결책으로 삼아 왔습니다.
의존성 트리의 최상단에는 rustc_driver와, 쿼리 시스템을 감싸 컴파일의 여러 단계를 구동하도록 돕는 불안정한 래퍼인 rustc_interface가 있습니다. 컴파일러를 사용하는 다른 도구는 이 인터페이스를 다른 방식으로 사용할 수 있습니다(예: rustdoc 또는 언젠가는 rust-analyzer). rustc_driver 크레이트는 먼저 명령줄 인자를 파싱한 다음, rustc_interface를 사용하여 컴파일을 끝까지 구동합니다.
rustdoc
rustdoc의 대부분은 librustdoc에 있습니다. 하지만 rustdoc 바이너리 자체는 src/tools/rustdoc이며, 이 바이너리는 rustdoc::main을 호출하는 것 외에는 아무 일도 하지 않습니다.
문서용 JavaScript와 CSS도 src/tools/rustdoc-js와 src/tools/rustdoc-themes에 있습니다. --output-format=json의 타입 정의는 src/rustdoc-json-types에 있는 별도의 크레이트에 있습니다.
rustdoc에 대한 자세한 내용은 이 장을 참고하십시오.
테스트
위의 모든 항목에 대한 테스트 스위트는 tests/에 있습니다. 테스트 스위트에 대한 자세한 내용은 이 장에서 확인하십시오.
테스트 하네스는 src/tools/compiletest/에 있습니다.
빌드 시스템
저장소에는 컴파일러, 표준 라이브러리, rustdoc 등을 빌드하고, 테스트하고, 전체 Rust 배포판을 빌드하는 등의 작업만을 위한 여러 도구가 있습니다.
주요 도구 중 하나는 src/bootstrap/입니다. 부트스트래핑에 대한 자세한 내용은 이 장에서 읽을 수 있습니다. 이 과정에서는 tidy/나 compiletest/처럼 src/tools/에 있는 다른 도구를 사용할 수도 있습니다.
표준 라이브러리
이 코드는 대부분의 다른 Rust 크레이트와 상당히 비슷하지만, 불안정한(nightly) 기능을 사용할 수 있기 때문에 특별한 방식으로 빌드해야 한다는 점이 다릅니다. 표준 라이브러리는 libstd or the "standard facade"라고 불리기도 합니다.
기타
rust-lang/rust 저장소에는 완전한 Rust 배포판을 빌드하는 일과 관련된 다른 것들이 많이 있습니다. 대부분의 경우에는 이것들을 신경 쓸 필요가 없습니다.
여기에는 다음이 포함됩니다.
쿼리: 요구 기반 컴파일
컴파일러 개요에서 설명한 것처럼, Rust 컴파일러는 여전히( 2021년 7월 기준) 전통적인 “패스 기반” 구성에서 “요구 기반” 시스템으로 전환하는 중입니다.
컴파일러의 “쿼리 시스템“은 rustc의 요구 기반 구조의 핵심입니다. 완전히 독립적인 패스(파싱, 타입 검사 등) 대신, 함수와 비슷한 일련의 쿼리가 입력 소스에 관한 정보를 계산합니다. 예를 들어, type_of라는 쿼리가 있는데, 이 쿼리는 어떤 항목의 DefId가 주어지면 그 항목의 타입을 계산하여 반환합니다.
쿼리 실행은 메모이제이션됩니다. 쿼리를 처음 호출하면 실제로 계산을 수행하지만, 그다음부터는 해시 테이블에서 결과가 반환됩니다. 게다가 쿼리 실행은 증분 계산과도 잘 맞아떨어집니다. 대략적인 아이디어는, 쿼리를 호출했을 때 디스크에 저장된 데이터를 불러와서 결과를 반환할 수도 있다는 것입니다.1
쿼리를 실행할 때, 그 쿼리가 어떤 다른 쿼리에 의존하는지도 (런타임에!) 알아냅니다. 이를 통해 크레이트의 의존성 그래프를 구성할 수 있으며, 이 그래프 덕분에 증분 계산의 건전성이 보장됩니다.
궁극적으로는 컴파일러의 전체 제어 흐름이 쿼리 기반으로 동작하기를 원합니다. 사실상 크레이트의 컴파일을 수행하는 최상위 쿼리(compile) 하나가 존재하게 되며, 이 쿼리는 다시 그 크레이트에 관한 정보를 끝에서부터 요구합니다.
예를 들면 다음과 같습니다.
compile쿼리는 코드 생성 단위(즉, LLVM이 컴파일해야 하는 모듈)의 목록을 요구할 수 있습니다.- 하지만 코드 생성 단위의 목록을 계산하려면 Rust 소스에 정의된 모든 모듈의 목록을 반환하는 하위 쿼리를 호출하게 됩니다.
- 그 쿼리는 다시 HIR을 요청하는 무언가를 호출하게 됩니다.
- 이런 과정이 점점 더 앞 단계로 거슬러 올라가다가 결국 실제 파싱을 수행하는 데까지 이릅니다.
이 구상이 완전히 실현된 것은 아니지만, 현재 컴파일러의 상당 부분(예를 들어 MIR 생성)은 정확히 이런 방식으로 동작합니다.
쿼리 호출하기
쿼리를 호출하는 방법은 간단합니다. TyCtxt(“타입 컨텍스트”) 구조체는 정의된 각 쿼리에 대한 메서드를 제공합니다. 예를 들어, type_of 쿼리를 호출하려면 다음과 같이 하기만 하면 됩니다.
let ty = tcx.type_of(some_def_id);
컴파일러가 쿼리를 실행하는 방식
그렇다면 쿼리 메서드를 호출할 때 무슨 일이 일어나는지 궁금할 수 있습니다. 답은 이렇습니다. 컴파일러는 각 쿼리마다 캐시를 유지합니다. 쿼리가 이미 실행된 적이 있다면 답은 간단합니다. 캐시에서 반환값을 복제하여 반환합니다(따라서 쿼리의 반환 타입은 적은 비용으로 복제할 수 있도록 해야 합니다. 필요하다면 Rc를 넣으십시오).
프로바이더
그러나 쿼리가 캐시에 없다면, 컴파일러는 해당하는 프로바이더 함수를 호출합니다. 프로바이더는 특정 모듈에 구현된 함수로, 컴파일러 초기화 과정에서 Providers 구조체(로컬 크레이트 쿼리용) 또는 ExternProviders 구조체(외부 크레이트 쿼리용)에 수동으로 등록됩니다. 매크로 시스템이 두 구조체를 모두 생성하며, 이 구조체들은 모든 쿼리 구현에 대한 함수 테이블 역할을 합니다. 각 필드는 실제 프로바이더를 가리키는 함수 포인터입니다.
참고: Providers 구조체와 ExternProviders 구조체는 모두 매크로로 생성되며, 모든 쿼리 구현을 위한 함수 테이블 역할을 합니다. 이들은 Rust 트레이트가 아니라 함수 포인터 필드를 가진 평범한 구조체입니다.
프로바이더는 크레이트별로 정의됩니다. 컴파일러는 적어도 개념적으로는 모든 크레이트에 대한 프로바이더 테이블을 내부적으로 유지합니다. 프로바이더에는 두 가지 집합이 있습니다:
- 로컬 크레이트(즉, 현재 컴파일 중인 크레이트)에 대한 쿼리를 위한
Providers구조체 - 외부 크레이트(즉, 로컬 크레이트의 의존성)에 대한 쿼리를 위한
ExternProviders구조체
쿼리가 대상으로 하는 크레이트를 결정하는 것은 쿼리의 종류가 아니라 키라는 점에 유의하십시오. 예를 들어 tcx.type_of(def_id)를 호출하면, def_id가 어떤 크레이트를 가리키는지에 따라 로컬 쿼리가 될 수도 있고 외부 쿼리가 될 수도 있습니다(이것이 어떻게 동작하는지에 대한 자세한 내용은 self::keys::QueryKey 트레이트를 참고하십시오).
프로바이더는 항상 같은 시그니처를 가집니다:
fn provider<'tcx>(
tcx: TyCtxt<'tcx>,
key: QUERY_KEY,
) -> QUERY_RESULT {
...
}
프로바이더는 tcx와 쿼리 키라는 두 인자를 받습니다. 프로바이더는 쿼리의 결과를 반환합니다.
참고: 대부분의 rustc_* 크레이트는 로컬 프로바이더만 제공합니다. 거의 모든 외부 프로바이더는 결국 크레이트 메타데이터에서 정보를 불러오는 rustc_metadata 크레이트를 거치게 됩니다. 하지만 경우에 따라 로컬 크레이트와 외부 크레이트 모두에 대한 쿼리를 제공하는 크레이트도 있으며, 이 경우 해당 크레이트는 wasm_import_module_map을 통해 rustc_driver가 호출할 수 있는 provide 함수와 provide_extern 함수를 모두 정의합니다.
프로바이더 설정 방식
tcx가 생성될 때, 생성하는 쪽에서 rustc_middle::util의 Providers 구조체를 사용하여 tcx에 로컬 프로바이더와 외부 프로바이더를 모두 전달합니다. 이 구조체는 로컬 프로바이더와 외부 프로바이더를 모두 포함합니다:
pub struct Providers {
pub queries: crate::query::Providers, // Local crate providers
pub extern_queries: crate::query::ExternProviders, // External crate providers
pub hooks: crate::hooks::Providers,
}
이 프로바이더 구조체들은 각각 매크로로 생성되며, 해당 쿼리에 대한 함수 포인터를 담고 있습니다.
프로바이더는 어떻게 등록됩니까?
util::Providers 구조체는 컴파일러 초기화 중에 rustc_interface 크레이트가 DEFAULT_QUERY_PROVIDERS 정적 변수로부터 채웁니다. 실제 프로바이더 함수는 여러 rustc_* 크레이트(rustc_middle, rustc_hir_analysis 등)에 걸쳐 정의되어 있습니다.
프로바이더를 등록하기 위해 각 크레이트는 다음과 같은 형태의 provide 함수를 노출합니다:
pub fn provide(providers: &mut query::Providers) {
*providers = query::Providers {
type_of,
// ... add more providers here
..*providers
};
}
이 함수는 util::Providers가 아니라 query::Providers를 받는다는 점에 유의하십시오. query::Providers만 받는 것이 아닌 provide 함수가 필요한 경우는 극히 드뭅니다. util::Providers의 queries 필드 외의 부분도 갱신해야 한다면 대신 util::Providers를 받을 수 있습니다:
pub fn provide(providers: &mut rustc_middle::util::Providers) {
providers.queries.type_of = type_of;
// ... add more local providers here
providers.extern_queries.type_of = extern_type_of;
// ... add more external providers here
providers.hooks.some_hook = some_hook;
// ... add more hooks here
}
새 프로바이더 추가하기
fubar라는 새 쿼리를 추가하려 한다고 가정해 봅시다. 이 절에서는 프로바이더를 연결하는 방법을 중점적으로 다룹니다. 거대한 rustc_queries! 매크로에서 쿼리 자체를 선언하는 방법은 아래의 새 쿼리 추가하기를 참고하십시오.
실제로는 보통 다음과 같이 합니다:
- 어떤 크레이트가 쿼리를 “소유“할지 결정합니다(예:
rustc_hir_analysis,rustc_mir_build또는 다른rustc_*크레이트). - 해당 크레이트에서 기존
provide함수를 찾습니다:
해당 함수가 이미 있다면 이를 확장하여 새 쿼리의 필드를 설정하면 됩니다. 크레이트에 아직pub fn provide(providers: &mut query::Providers) { // existing assignments }provide함수가 없다면 하나를 추가하고, 초기화 과정에서 실제로 호출되도록rustc_interface크레이트의DEFAULT_QUERY_PROVIDERS에 포함되어 있는지 확인하십시오(위의 논의를 참고하십시오). - 프로바이더 함수 자체를 구현하십시오.
fn fubar<'tcx>(tcx: TyCtxt<'tcx>, key: LocalDefId) -> Fubar<'tcx> { ... } - 크레이트의
provide함수에 이를 등록하십시오:pub fn provide(providers: &mut query::Providers) { *providers = query::Providers { fubar, ..*providers }; }
쿼리가 외부 크레이트 메타데이터와 상호작용하는 방식
외부 크레이트(즉, 의존성)에 대해 쿼리가 수행되면, 쿼리 시스템은 해당 크레이트의 메타데이터에서 정보를 불러와야 합니다. 이 작업은 rustc_metadata 크레이트가 처리하며, 이 크레이트는 .rmeta 파일에 저장된 정보를 디코딩하고 제공하는 역할을 맡습니다.
이 과정은 다음과 같이 동작합니다:
-
쿼리가 수행되면 쿼리 시스템은 먼저
def_id.krate == LOCAL_CRATE인지 확인하여DefId가 로컬 크레이트를 가리키는지 외부 크레이트를 가리키는지 검사합니다. 이를 통해Providers의 로컬 프로바이더를 사용할지,ExternProviders의 외부 프로바이더를 사용할지가 결정됩니다. -
외부 크레이트의 경우, 쿼리 시스템은
ExternProviders구조체에서 프로바이더를 찾습니다.rustc_metadata크레이트는rustc_metadata/src/rmeta/decoder/cstore_impl.rs에 있는provide_extern함수를 통해 이러한 외부 프로바이더를 등록합니다. 예를 들면 다음과 같습니다:#![allow(unused)] fn main() { pub fn provide_extern(providers: &mut ExternProviders) { providers.foo = |tcx, def_id| { // Load and decode metadata for external crate let cdata = CStore::from_tcx(tcx).get_crate_data(def_id.krate); cdata.foo(def_id.index) }; // Register other external providers... } } -
메타데이터는
.rmeta파일에 바이너리 형식으로 저장되며, 여기에는 타입, 함수 시그니처, 트레이트 구현을 비롯해 컴파일러가 필요로 하는 기타 정보 등 외부 크레이트에 대해 미리 계산된 정보가 담겨 있습니다. 외부 쿼리가 수행되면rustc_metadata크레이트는 다음을 수행합니다:- 외부 크레이트의
.rmeta파일을 불러옵니다 Decodable트레이트를 사용하여 메타데이터를 디코딩합니다- 디코딩된 정보를 쿼리 시스템에 반환합니다
- 외부 크레이트의
이 방식은 외부 크레이트를 다시 컴파일하지 않아도 되게 하고, 의존하는 크레이트를 더 빠르게 컴파일할 수 있게 하며, 증분 컴파일이 크레이트 경계를 넘어 동작할 수 있게 합니다. 간단한 예를 들면, 외부 크레이트에 정의된 타입에 대해 tcx.type_of(def_id)를 호출하면 쿼리 시스템은 다음과 같이 동작합니다:
def_id.krate != LOCAL_CRATE를 검사하여def_id가 외부 크레이트를 가리킨다는 것을 감지합니다rustc_metadata가 등록한ExternProviders에서 적절한 프로바이더를 호출합니다- 프로바이더는 외부 크레이트의 메타데이터에서 타입 정보를 불러와 디코딩합니다
- 디코딩된 타입을 호출자에게 반환합니다
이 때문에 대부분의 rustc_* 크레이트는 로컬 프로바이더만 제공하면 됩니다. 외부 프로바이더는 메타데이터 시스템이 처리합니다. 유일한 예외는 크레이트가 외부 쿼리에 대해 특별한 처리를 제공해야 하는 경우이며, 이때는 로컬 프로바이더와 외부 프로바이더를 모두 구현합니다.
크레이트 간에 동작해야 하는 새 쿼리를 정의할 때, 그 쿼리가 rustc_queries!에 나열되었다는 이유만으로 자동으로 크레이트 간 쿼리가 되지는 않습니다. 일반적으로 다음 작업이 필요합니다:
- 적절한 수정자(예를 들어 디스크에 캐시되는지 여부)와 함께
rustc_queries!에 쿼리를 추가하십시오. - 해당 쿼리를 소유한 크레이트에 로컬 프로바이더를 구현하고, 그 크레이트의
provide함수를 통해 등록하십시오. provide_extern을 통해rustc_metadata에 외부 프로바이더를 추가하고, 쿼리 결과가 크레이트 메타데이터에 인코딩 및 디코딩되도록 하십시오.
이러한 크레이트 간 쿼리를 도입하는 예시는 rust-lang/rust 저장소의 커밋 996a185에서 찾을 수 있습니다.
새 쿼리 추가하기
새 쿼리는 어떻게 추가합니까? 쿼리 정의는 두 단계로 이루어집니다:
- 쿼리 이름, 인자, 설명을 선언하십시오.
- 필요한 곳에 쿼리 프로바이더를 제공하십시오.
쿼리 이름과 인자를 선언하려면 compiler/rustc_middle/src/query/mod.rs에 있는 큰 매크로 호출에 항목을 하나 추가하기만 하면 됩니다. 그런 다음 여기에 내부용 설명을 담은 문서 주석을 추가해야 합니다. 그런 다음 쿼리에 대한 사용자용 설명을 담은 desc 속성을 제공하십시오. desc 속성은 쿼리 순환이 발생했을 때 사용자에게 표시됩니다.
대략 다음과 같은 모습입니다:
rustc_queries! {
/// Records the type of every item.
query type_of(key: DefId) -> Ty<'tcx> {
desc { |tcx| "computing the type of `{}`", tcx.def_path_str(key) }
cache_on_disk
separate_provide_extern
}
...
}
쿼리 정의는 다음과 같은 형식을 가집니다:
query type_of(key: DefId) -> Ty<'tcx> { ... }
^^^^^ ^^^^^^^ ^^^^^ ^^^^^^^^ ^^^
| | | | |
| | | | query modifiers
| | | result type
| | query key type
| name of query
query keyword
이 요소들을 하나씩 살펴봅시다:
- 쿼리 키워드: 쿼리 정의의 시작을 나타냅니다.
- 쿼리 이름: 쿼리 메서드의 이름(
tcx.type_of(..))입니다. 이 쿼리를 나타내기 위해 생성될 구조체(ty::queries::type_of)의 이름으로도 사용됩니다. - 쿼리 키 타입: 이 쿼리에 전달되는 인자의 타입입니다. 이 타입은
ty::query::keys::QueryKey트레이트를 구현해야 하며, 이 트레이트는 (예를 들어) 해당 타입을 크레이트에 매핑하는 방법 등을 정의합니다. - 쿼리의 결과 타입: 이 쿼리가 생성하는 타입입니다. 이 타입은 (a)
RefCell이나 그 밖의 내부 가변성을 사용하지 않아야 하며 (b) 저렴하게 복제할 수 있어야 합니다. 단순하지 않은 데이터 타입에는 인터닝을 하거나Rc또는Arc를 사용하는 것을 권장합니다.2 - 쿼리 수식자: 쿼리가 처리되는 방식을 (주로 증분 컴파일과 관련하여) 조정하는 다양한 플래그와 옵션입니다.
따라서 쿼리를 추가하려면 다음과 같이 하십시오:
- 위의 형식을 사용하여
rustc_queries!에 항목을 추가하십시오. - 적절한
provide메서드를 수정하여 프로바이더를 연결하십시오. 또는 필요하다면 새 메서드를 추가하고rustc_driver가 그 메서드를 호출하는지 확인하십시오.
외부 링크
관련 설계 아이디어 및 추적 이슈:
추가 논의 및 이슈:
쿼리 평가 모델 상세
이 장에서는 쿼리의 기반이 되는 추상 모델을 더 깊이 살펴봅니다. 구현 세부 사항까지 다루지는 않으며, 그 밑바탕에 있는 논리를 설명하고자 합니다. 따라서 여기에 나오는 예제는 간추리고 단순화한 것이며, 컴파일러 내부 API를 그대로 반영하지는 않습니다.
쿼리란 무엇입니까?
추상적으로 보면, 우리는 주어진 크레이트에 대한 컴파일러의 지식을 하나의 “데이터베이스“로 보며, 쿼리는 그에 관해 컴파일러에 질문하는 방법입니다. 즉, 우리는 컴파일러의 “데이터베이스“에 사실을 “쿼리“합니다.
그런데 이 컴파일러 데이터베이스에는 특별한 점이 있습니다. 처음에는 비어 있다가 쿼리가 실행될 때 필요에 따라 채워진다는 점입니다. 따라서 쿼리는 데이터베이스에 아직 결과가 없을 때 그 결과를 어떻게 계산하는지 알고 있어야 합니다. 이를 위해 쿼리는 다른 쿼리와, 데이터베이스가 생성될 때 미리 채워지는 특정 입력 값에 접근할 수 있습니다.
따라서 쿼리는 다음과 같은 요소로 구성됩니다.
- 쿼리를 식별하는 이름
- 무엇을 조회하려는지 지정하는 “키”
- 어떤 종류의 결과를 내는지 지정하는 결과 타입
- 데이터베이스에 결과가 아직 없을 때 결과를 어떻게 계산할지 지정하는 함수인 “프로바이더”
예를 들어, type_of 쿼리의 이름은 type_of이고, 쿼리 키는 타입을 알고자 하는 항목을 식별하는 DefId이며, 결과 타입은 Ty<'tcx>이고, 프로바이더는 쿼리 키와 나머지 데이터베이스에 대한 접근이 주어졌을 때 키로 식별되는 항목의 타입을 계산할 수 있는 함수입니다.
그러므로 어떤 의미에서 쿼리는 쿼리 키를 그에 대응하는 결과로 매핑하는 함수일 뿐입니다. 하지만 이것이 건전(sound)하려면 몇 가지 제약을 적용해야 합니다.
- 키와 결과는 불변 값이어야 합니다.
- 프로바이더 함수는 같은 키에 대해 항상 같은 결과를 내야 한다는 의미에서 순수 함수여야 합니다.
- 프로바이더 함수가 받는 매개변수는 키와 “쿼리 컨텍스트”(나머지 “데이터베이스“에 대한 접근을 제공합니다)에 대한 참조뿐입니다.
데이터베이스는 쿼리가 호출됨에 따라 지연(lazy) 방식으로 구축됩니다. 쿼리 프로바이더는 다른 쿼리를 호출하며, 그 결과는 이미 캐시되어 있거나 다른 쿼리 프로바이더를 호출하여 계산됩니다. 이러한 쿼리 프로바이더 호출은 개념적으로 방향성 비순환 그래프(DAG)를 이루며, 그 잎(leaf)에는 쿼리 컨텍스트가 생성될 때 이미 알려져 있는 입력 값이 있습니다.
캐싱/메모이제이션
쿼리 호출 결과는 “메모이제이션“됩니다. 즉, 쿼리 컨텍스트는 결과를 내부 테이블에 캐시하고, 같은 쿼리 키로 쿼리가 다시 호출되면 프로바이더를 다시 실행하는 대신 캐시에서 결과를 반환합니다.
이 캐싱은 쿼리 엔진을 효율적으로 만드는 데 매우 중요합니다. 메모이제이션이 없어도 시스템은 여전히 건전하겠지만(즉, 같은 결과를 내겠지만) 같은 계산이 계속해서 반복될 것입니다.
메모이제이션은 쿼리 프로바이더가 순수 함수여야 하는 주된 이유 중 하나입니다. 프로바이더 함수를 호출할 때마다 (어떤 전역 가변 상태에 접근하기 때문에) 다른 결과가 나올 수 있다면, 그 결과를 메모이제이션할 수 없을 것입니다.
입력 데이터
쿼리 컨텍스트는 생성될 때 아직 비어 있습니다. 실행된 쿼리도 없고, 캐시된 결과도 없습니다. 하지만 컨텍스트는 이미 “입력” 데이터, 즉 컨텍스트가 생성되기 전에 계산되었으며 쿼리가 계산을 수행하기 위해 접근할 수 있는 불변 데이터 조각에 대한 접근을 제공합니다.
2021년 1월 기준으로, 이 입력 데이터는 주로 HIR 맵, 업스트림 크레이트 메타데이터, 그리고 컴파일러를 호출할 때 지정한 명령줄 옵션으로 구성됩니다. 하지만 앞으로는 입력이 명령줄 옵션과 소스 파일 목록만으로 구성될 것입니다. HIR 맵 자체는 이 소스 파일들을 처리하는 쿼리가 제공하게 됩니다.입력이 없다면 쿼리는 결과를 계산할 근거가 전혀 없는 공백 속에 놓이게 됩니다(쿼리 프로바이더는 다른 쿼리와 컨텍스트에만 접근할 수 있으며, 그 밖의 외부 상태나 정보에는 접근할 수 없다는 점을 기억하십시오).
쿼리 프로바이더 입장에서는 입력 데이터와 다른 쿼리의 결과가 완전히 똑같아 보입니다. 프로바이더는 컨텍스트에 “X의 값을 달라“고 요청할 뿐입니다. 입력 데이터는 불변이므로, 쿼리 결과와 마찬가지로 프로바이더는 입력 데이터가 서로 다른 쿼리 호출 사이에서도 동일하다고 믿을 수 있습니다.
몇 가지 쿼리의 실행 추적 예시
쿼리 호출로 이루어진 이 DAG는 어떻게 생겨나는 것입니까? 어느 시점에 컴파일러 드라이버가 아직 비어 있는 쿼리 컨텍스트를 생성합니다. 그런 다음 드라이버는 쿼리 시스템 외부에서 자신의 작업을 수행하는 데 필요한 쿼리를 호출합니다. 이는 대략 다음과 같습니다:
fn compile_crate() {
let cli_options = ...;
let hir_map = ...;
// Create the query context `tcx`
let tcx = TyCtxt::new(cli_options, hir_map);
// Do type checking by invoking the type check query
tcx.type_check_crate();
}
type_check_crate 쿼리 프로바이더는 대략 다음과 같은 모습입니다.
fn type_check_crate_provider(tcx, _key: ()) {
let list_of_hir_items = tcx.hir_map.list_of_items();
for item_def_id in list_of_hir_items {
tcx.type_check_item(item_def_id);
}
}
type_check_crate 쿼리가 입력 데이터(tcx.hir_map.list_of_items())에 접근하고 다른 쿼리(type_check_item)를 호출하는 것을 볼 수 있습니다. type_check_item 호출은 그 자체로 입력 데이터에 접근하거나 다른 쿼리를 호출하거나 둘 다 수행하므로, 결국 쿼리 호출의 DAG는 처음 실행된 노드로부터 거꾸로 구축됩니다:
(2) (1)
list_of_all_hir_items <----------------------------- type_check_crate()
|
(5) (4) (3) |
Hir(foo) <--- type_of(foo) <--- type_check_item(foo) <-------+
| |
+-----------------+ |
| |
(7) v (6) (8) |
Hir(bar) <--- type_of(bar) <--- type_check_item(bar) <-------+
// (x) denotes invocation order
또한 쿼리 결과를 캐시에서 읽을 수 있는 경우가 많다는 것도 볼 수 있습니다. type_of(bar)는 type_check_item(foo)를 위해 계산되었으므로, type_check_item(bar)가 이를 필요로 할 때에는 이미 캐시에 들어 있습니다.
쿼리 결과는 컨텍스트가 살아 있는 동안 쿼리 컨텍스트에 캐시된 채로 유지됩니다. 따라서 컴파일러 드라이버가 나중에 다른 쿼리를 호출하더라도 위의 그래프는 여전히 존재하며, 이미 실행된 쿼리를 다시 수행할 필요가 없습니다.
순환
앞에서 쿼리 호출이 DAG를 형성한다고 말했습니다. 그러나 예를 들어 다음과 같은 쿼리 프로바이더를 두면 순환 그래프를 쉽게 만들 수 있습니다.
fn cyclic_query_provider(tcx, key) -> u32 {
// Invoke the same query with the same key again
tcx.cyclic_query(key)
}
쿼리 프로바이더는 일반 함수이므로, 이 코드는 예상대로 동작합니다. 즉, 평가가 무한 재귀에 빠집니다. 이와 같은 쿼리는 별로 쓸모도 없습니다. 하지만 때로는 특정 종류의 잘못된 사용자 입력으로 인해 쿼리가 순환적으로 호출될 수 있습니다. 쿼리 엔진에는 동일한 입력 인자로 쿼리가 순환 호출되는지 검사하는 기능이 포함되어 있습니다. 그리고 순환은 복구할 수 없는 오류이므로, 쿼리 엔진은 사람이 읽기 쉽도록 작성된 “순환 오류(cycle error)” 메시지와 함께 실행을 중단합니다.
한때 컴파일러에는 “순환 복구(cycle recovery)“라는 개념이 있었습니다. 즉, 쿼리 실행을 “시도“해 보고, 그 결과 순환이 발생하면 다른 방식으로 진행할 수 있었습니다. 하지만 이것이 이론적으로, 특히 증분 컴파일과 관련하여 어떤 결과를 낳는지가 완전히 명확하지 않았기 때문에 나중에 제거되었습니다.
“훔치기(Steal)” 쿼리
일부 쿼리는 결과가 Steal<T> 구조체로 감싸여 있습니다. 이러한 쿼리는 한 가지 예외를 제외하면 일반 쿼리와 완전히 똑같이 동작합니다. 그 예외란 결과가 어느 시점에 캐시에서 “도난“당할 것으로 예상된다는 점입니다. 이는 프로그램의 다른 부분이 결과의 소유권을 가져가며, 이후로는 그 결과에 더 이상 접근할 수 없다는 뜻입니다.
이 훔치기 메커니즘은 순전히 성능 최적화를 위해 존재합니다. 일부 결괏값(예: 함수의 MIR)은 복제하는 데 비용이 너무 크기 때문입니다. 결과 훔치기는 쿼리 결과가 불변이어야 한다는 조건을 위반하는 것처럼 보일 수 있지만(결국 결괏값을 캐시 밖으로 옮기는 것이기 때문입니다), 그 변경이 관찰될 수 없는 한 문제가 없습니다. 이는 두 가지를 통해 달성됩니다.
- 결과를 훔치기 전에, 그 결과를 읽어야 할 가능성이 있는 모든 쿼리를 반드시 미리 실행합니다. 이는 해당 쿼리들을 호출하여 수동으로 수행해야 합니다.
- 쿼리가 훔쳐진 결과에 접근하려고 할 때마다 ICE(Internal Compiler Error, 내부 컴파일러 오류)를 발생시켜 이러한 상황이 눈에 띄지 않고 지나가는 일이 없도록 합니다.
수동 개입이 필요하므로 이는 이상적인 구성이 아닙니다. 따라서 드물게, 그리고 어떤 쿼리가 주어진 결과에 접근할 수 있는지 잘 알려진 경우에만 사용해야 합니다. 그러나 실제로는 훔치기가 유지 보수에 큰 부담이 되지는 않았습니다.
요약하면, “훔치기 쿼리“는 통제된 방식으로 일부 규칙을 어깁니다. 아무것도 조용히 잘못되는 일이 없도록 보장하는 검사가 마련되어 있습니다.
증분 컴파일
증분 컴파일 방식은 본질적으로 전체 쿼리 시스템을 놀라울 만큼 간단하게 확장한 것입니다. 먼저 실제 구현을 약간 단순화한 변형인 “기본 알고리즘“을 설명한 다음, 가능한 몇 가지 개선 사항을 설명하겠습니다.
기본 알고리즘
기본 알고리즘은 레드-그린 알고리즘1이라고 부릅니다. 핵심 아이디어는 컴파일러를 실행할 때마다 수행한 모든 쿼리의 결과와 쿼리 DAG를 저장한다는 것입니다. 쿼리 DAG는 어떤 쿼리가 다른 어떤 쿼리를 실행했는지를 색인하는 DAG입니다. 예를 들어 Q1을 계산하는 데 Q2의 계산이 필요했다면 쿼리 Q1에서 다른 쿼리 Q2로 가는 간선이 존재합니다(쿼리는 자기 자신에 의존할 수 없으므로 그 결과는 일반 그래프가 아닌 DAG가 된다는 점에 유의하십시오).
참고: 쿼리를 단순히 쿼리의 정의로 생각할 수도 있습니다. 즉, 함수와 비슷하게 호출할 수 있고, 캐시된 결과를 반환하거나 실제로 코드를 실행하는 것입니다.
쿼리를 그렇게 생각하고 있다면, 이어지는 글에서 쿼리가 색을 가진다고 표현한다는 점을 알아 두는 것이 좋습니다. 다만 여기서 쿼리라는 단어는 특정 입력에 대한 쿼리의 특정 호출을 가리키기도 한다는 점을 명심하십시오. 뒤에서 읽게 되겠지만, 쿼리는 인자를 기반으로 핑거프린트(fingerprint)가 매겨집니다. 쿼리의 결과는 어떤 인자를 주었을 때는 바뀌어 빨간색이 될 수 있고, 다른 인자에 대해서는 그대로 유지되어 초록색이 될 수 있습니다.
요컨대 여기서 쿼리라는 단어는 쿼리의 정의만을 뜻하는 것이 아니라, 주어진 인자를 가진 해당 쿼리의 특정 인스턴스를 뜻하기도 합니다.
그러면 다음번 컴파일러 실행에서는 이 쿼리 결과를 재사용하여 쿼리를 다시 실행하지 않아도 되는 경우가 있습니다. 이를 위해 모든 쿼리에 색을 지정합니다:
- 쿼리가 빨간색이라면, 이번 컴파일에서의 결과가 이전 컴파일과 비교해 바뀌었다는 뜻입니다.
- 쿼리가 초록색이라면, 그 결과가 이전 컴파일과 같다는 뜻입니다.
여기에는 두 가지 핵심 통찰이 있습니다:
- 첫째, 쿼리 Q의 모든 입력이 초록색이라면 쿼리 Q는 지난번과 같은 값을 반드시 내야 하므로 다시 실행할 필요가 없습니다(그렇지 않다면 컴파일러가 결정적이지 않은 것입니다).
- 둘째, 쿼리의 일부 입력이 바뀌더라도 그 쿼리는 여전히 이전 컴파일과 같은 결과를 낼 수 있습니다. 특히 쿼리는 입력의 일부만 사용할 수도 있습니다.
- 따라서 쿼리를 실행한 후에는 항상 이전과 같은 결과를 냈는지 확인합니다. 같은 결과를 냈다면 여전히 그 쿼리를 초록색으로 표시할 수 있고, 따라서 의존하는 쿼리를 다시 실행하지 않아도 됩니다.
try-mark-green 알고리즘
증분 컴파일의 핵심에는 “try-mark-green“이라는 알고리즘이 있습니다. 이 알고리즘은 주어진 쿼리 Q(아직 실행되지 않았어야 함)의 색을 결정하는 역할을 합니다. Q에 빨간색 입력이 있는 경우 Q의 색을 결정하려면 출력을 비교하기 위해 Q를 다시 실행해야 할 수도 있지만, Q의 입력이 모두 초록색이라면 Q를 다시 실행하거나 그 값을 전혀 살펴보지 않고도 Q가 초록색일 수밖에 없다고 결론 내릴 수 있습니다. 컴파일러에서는 이 덕분에 필요하지 않을 때 디스크에서 결과를 역직렬화하지 않아도 되며, 실제로 때로는 결과를 직렬화하는 것도 생략할 수 있습니다(아래의 개선 사항 절을 참고하십시오).
try-mark-green은 다음과 같이 동작합니다:
- 먼저 쿼리 Q가 이전 컴파일 중에 실행되었는지 확인합니다.
- 실행되지 않았다면 평소처럼 쿼리를 다시 실행하고 빨간색을 지정하면 됩니다.
- 그렇다면 Q의 ’의존 쿼리’를 불러옵니다.
- 저장된 결과가 있으면 쿼리 DAG에서
reads(Q)벡터를 불러옵니다. “reads“는 Q가 실행되는 동안 실행한 쿼리의 집합입니다.reads(Q)에 있는 각 쿼리 R에 대해 try-mark-green을 사용하여 R의 색을 재귀적으로 요청합니다.- 참고:
reads(Q)의 각 노드는 원래 컴파일에서 발생한 순서와 같은 순서로 방문해야 합니다. 아래의 쿼리 DAG 절을 참조하십시오. reads(Q)의 노드 중 어느 하나라도 최종적으로 빨간색으로 칠해지면 Q는 더티(dirty) 상태입니다.- Q를 다시 실행하고 그 결과의 해시를 이전 컴파일 결과의 해시와 비교합니다.
- 해시가 바뀌지 않았다면 Q를 초록색으로 표시하고 반환할 수 있습니다.
- 그렇지 않은 경우에는
reads(Q)의 노드가 모두 초록색이어야 합니다. 이 경우 Q를 초록색으로 칠하고 반환할 수 있습니다.
- 참고:
쿼리 DAG
쿼리 DAG 코드는 compiler/rustc_middle/src/dep_graph에 있습니다. DAG는 쿼리 실행을 계측하여 구성합니다.
핵심은 쿼리 DAG가 순서도 추적한다는 점입니다. 즉, 각 쿼리 Q에 대해 Q가 읽는 쿼리뿐만 아니라 그 쿼리들을 읽은 순서도 추적합니다. 덕분에 try-mark-green은 그 쿼리들을 같은 순서로 다시 따라갈 수 있습니다. 이것이 중요한 이유는 하위 쿼리가 빨간색으로 판정되는 순간부터는 Q가 이전과 같은 경로를 계속 따라갈 것이라고 더 이상 확신할 수 없기 때문입니다. 예를 들어 다음과 같은 쿼리를 생각해 보십시오.
fn main_query(tcx) {
if tcx.subquery1() {
tcx.subquery2()
} else {
tcx.subquery3()
}
}
이제 첫 번째 컴파일에서 main_query가 subquery1을 실행하는 것으로 시작하고, 이것이 true를 반환한다고 가정해 보십시오. 이 경우 main_query가 다음으로 실행하는 쿼리는 subquery2가 되며, subquery3는 전혀 실행되지 않습니다.
그런데 이제 다음 컴파일에서 입력이 바뀌어 subquery1이 false를 반환한다고 가정해 보십시오. 이 경우 subquery2는 결코 실행되지 않습니다. 그러나 try-mark-green이 reads(main_query)를 순서와 다르게 방문한다면 subquery1보다 먼저 subquery2를 방문하여 그것을 실행해 버릴 수도 있습니다. 이는 컴파일러에서 ICE를 비롯한 여러 문제를 일으킬 수 있습니다.
기본 알고리즘의 개선
기본 알고리즘을 설명할 때 컴파일이 끝나면 수행된 모든 쿼리의 결과를 저장한다고 했습니다. 실제로는 이 방식이 상당히 낭비일 수 있습니다. 그 결과 중 상당수는 다시 계산하는 비용이 매우 적어서, 직렬화하고 역직렬화해도 별다른 이득이 없습니다. 실제로는 수행한 모든 하위 쿼리의 해시를 저장합니다. 그리고 일부 경우에는 결과도 저장합니다.
이 때문에 증분 알고리즘은 노드의 색을 계산하는 일과 노드의 결과를 계산하는 일을 분리하며, 색을 계산할 때는 노드의 값이 필요하지 않은 경우가 많습니다. 결과는 다음과 같은 간단한 알고리즘으로 계산합니다.
- Q에 대해 저장된 결과가 있는지 확인합니다. 저장된 결과가 있으면 Q의 색을 계산합니다. Q가 초록색이면 저장된 결과를 역직렬화하여 반환합니다.
- 그렇지 않으면 Q를 실행합니다.
- 그런 다음 결과의 해시를 비교하여 결과가 바뀌지 않았으면 Q를 초록색으로 표시할 수 있습니다.
참고 자료
최초 설계 문서는 여기에서 볼 수 있습니다. 이 문서는 메모이제이션을 더 자세히 설명하고, 이 시스템의 전체적인 개요와 도입 동기도 제시합니다.
각주
-
오래전부터 이 알고리즘의 이름을 Salsa 알고리즘으로 바꾸고 싶었지만, 그 이름은 끝내 자리 잡지 못했습니다. -@nikomatsakis ↩
증분 컴파일 상세
증분 컴파일 방식은 본질적으로 전체 쿼리 시스템을 놀라울 만큼 간단하게 확장한 것입니다. 이 방식은 다음 사실에 기반합니다:
- 쿼리는 순수 함수입니다 – 같은 입력이 주어지면 쿼리는 항상 같은 결과를 내놓습니다. 그리고
- 쿼리 모델은 컴파일을 비순환 그래프로 구조화하여 개별 계산 사이의 의존성을 명시적으로 드러냅니다.
이 장에서는 이러한 속성을 활용하여 작업을 증분 방식으로 만드는 방법을 설명한 다음, 버전 구현 문제를 논의합니다.
증분 쿼리 평가를 위한 기본 알고리즘
쿼리 평가 모델 입문에서 설명한 것처럼, 쿼리 호출은 방향성 비순환 그래프를 형성합니다. 앞 장의 예시를 다시 보겠습니다:
list_of_all_hir_items <----------------------------- type_check_crate()
|
|
Hir(foo) <--- type_of(foo) <--- type_check_item(foo) <-------+
| |
+-----------------+ |
| |
v |
Hir(bar) <--- type_of(bar) <--- type_check_item(bar) <-------+
한 쿼리에서 다른 쿼리로의 모든 접근은 쿼리 컨텍스트를 거쳐야 하므로, 이러한 접근을 기록하여 이 의존성 그래프를 메모리에 실제로 빌드할 수 있습니다. 의존성 추적을 활성화하면, 컴파일이 끝났을 때 어떤 쿼리가 호출되었는지(그래프의 노드)와 각 호출에 대해 어떤 다른 쿼리나 입력이 해당 쿼리의 결과를 계산하는 데 사용되었는지(그래프의 간선)를 알 수 있습니다.
이제 bar의 HIR이 이전과 달라지도록 프로그램의 소스 코드를 변경한다고 가정해 봅시다. 우리의 목표는 변경의 영향을 실제로 받는 쿼리만 다시 계산하고, 나머지 모든 쿼리는 캐시된 결과를 재사용하는 것입니다. 의존성 그래프가 있으면 정확히 그렇게 할 수 있습니다. 주어진 쿼리 호출에 대해 그래프는 그 결과를 계산하는 데 어떤 데이터가 사용되었는지 정확히 알려 주므로, 변경된 무언가에 도달할 때까지 간선을 따라가기만 하면 됩니다. 변경된 것을 하나도 만나지 않는다면, 해당 쿼리가 여전히 캐시에 이미 있는 결과와 같은 결과로 평가된다는 것을 알 수 있습니다.
위의 type_of(foo) 호출을 예로 들면, 입력으로 향하는 간선을 따라가서 캐시된 결과가 여전히 유효한지 확인할 수 있습니다. 유일한 간선은 변경의 영향을 받지 않은 입력인 Hir(foo)로 이어집니다. 따라서 type_of(foo)에 대해 캐시된 결과가 여전히 유효하다는 것을 알 수 있습니다.
type_check_item(foo)의 경우는 조금 다릅니다: 다시 간선을 따라가면 type_of(foo)에는 문제가 없다는 것을 이미 알고 있습니다. 그다음 아직 확인하지 않은 type_of(bar)에 도달하므로 type_of(bar)의 간선을 따라가는데, 여기서 실제로 변경된 Hir(bar)를 만나게 됩니다. 그 결과 type_of(bar)는 캐시에 있는 것과 다른 결과를 낼 수 있으며, 이행적으로 type_check_item(foo)의 결과 역시 달라졌을 수 있습니다. 따라서 type_check_item(foo)를 다시 실행하고, 이는 다시 type_of(bar)를 재실행하며, type_of(bar)는 최신 버전의 Hir(bar)를 읽으므로 최신 결과를 내놓습니다. 또한 type_of(bar)의 결과가 달라졌을 수 있으므로 type_check_item(bar)도 다시 실행합니다.
기본 알고리즘의 문제점: 거짓 양성
앞 단락을 주의 깊게 읽었다면, 입력 중 하나가 변경되었기 때문에 type_of(bar)가 변경되었을 수도 있다고 적혀 있다는 점을 알아차렸을 것입니다. 입력이 변경되었음에도 여전히 정확히 같은 결과를 낼 가능성도 있습니다. 정수의 부호만 계산하는 간단한 쿼리를 예로 들어 보겠습니다:
IntValue(x) <---- sign_of(x) <--- some_other_query(x)
IntValue(x)가 처음에 1000이었다가 이후 2000으로 설정된다고 합시다. 두 경우에 IntValue(x)는 서로 다르지만, sign_of(x)는 두 경우 모두 +라는 결과를 내놓습니다.
그러나 기본 알고리즘을 따르면, some_other_query(x)는 변경된 입력에 이행적으로 의존하므로 (불필요하게) 다시 평가되어야 합니다. 이 경우 변경 감지는 some_other_query(x)가 변경된 입력의 영향을 받을 수 있다고 보수적으로 가정해야 하므로 “거짓 양성“을 내놓습니다.
안타깝게도 실제 컴파일러의 쿼리에는 이와 같은 예가 가득하며, 입력의 작은 변경이 출력 바이너리의 매우 큰 부분에 영향을 줄 가능성이 있는 경우가 많습니다. 그 결과 변경 감지 시스템을 더 똑똑하고 정확하게 만들어야 했습니다.
정확도 개선: 레드-그린 알고리즘
“거짓 양성(false positive)” 문제는 변경 감지와 쿼리 재평가를 교차로 수행하여 해결할 수 있습니다. 어떤 캐시된 결과가 여전히 유효한지 알아내려 할 때 그래프를 입력까지 끝까지 따라가는 대신, 결과를 어쩔 수 없이 재평가한 뒤에 그 결과가 실제로 변경되었는지 확인할 수 있습니다.
이 알고리즘을 레드-그린 알고리즘이라고 부르는 이유는, 의존성 그래프의 노드에 대해 캐시된 결과가 여전히 유효함을 증명할 수 있으면 녹색을, 재평가한 뒤 결과가 달라진 것으로 판명되면 빨간색을 지정하기 때문입니다.
레드-그린 변경 추적의 핵심은 try-mark-green 알고리즘에 구현되어 있으며, 짐작하셨겠지만 이 알고리즘은 주어진 노드를 녹색으로 표시하려고 시도합니다:
fn try_mark_green(tcx, current_node) -> bool {
// Fetch the inputs to `current_node`, i.e. get the nodes that the direct
// edges from `node` lead to.
let dependencies = tcx.dep_graph.get_dependencies_of(current_node);
// Now check all the inputs for changes
for dependency in dependencies {
match tcx.dep_graph.get_node_color(dependency) {
Green => {
// This input has already been checked before and it has not
// changed; so we can go on to check the next one
}
Red => {
// We found an input that has changed. We cannot mark
// `current_node` as green without re-running the
// corresponding query.
return false
}
Unknown => {
// This is the first time we look at this node. Let's try
// to mark it green by calling try_mark_green() recursively.
if try_mark_green(tcx, dependency) {
// We successfully marked the input as green, on to the
// next.
} else {
// We could *not* mark the input as green. This means we
// don't know if its value has changed. In order to find
// out, we re-run the corresponding query now!
tcx.run_query_for(dependency);
// Fetch and check the node color again. Running the query
// has forced it to either red (if it yielded a different
// result than we have in the cache) or green (if it
// yielded the same result).
match tcx.dep_graph.get_node_color(dependency) {
Red => {
// The input turned out to be red, so we cannot
// mark `current_node` as green.
return false
}
Green => {
// Re-running the query paid off! The result is the
// same as before, so this particular input does
// not invalidate `current_node`.
}
Unknown => {
// There is no way a node has no color after
// re-running the query.
panic!("unreachable")
}
}
}
}
}
}
// If we have gotten through the entire loop, it means that all inputs
// have turned out to be green. If all inputs are unchanged, it means
// that the query result corresponding to `current_node` cannot have
// changed either.
tcx.dep_graph.mark_green(current_node);
true
}
참고: 실제 구현은
compiler/rustc_middle/src/dep_graph/graph.rs에서 찾을 수 있습니다.
레드-그린 표시를 사용하면 변경 감지 중 거짓 양성이 누적되어 생기는 치명적인 효과를 피할 수 있습니다. 증분 모드에서 쿼리가 실행될 때마다, 먼저 그 쿼리가 이미 녹색인지 확인합니다. 녹색이 아니라면 그 쿼리에 대해 try_mark_green()을 실행합니다. 그 후에도 여전히 green이 아니라면, 실제로 쿼리 프로바이더를 호출하여 결과를 다시 계산합니다. 쿼리를 다시 계산하는 과정에서 더 많은 쿼리를 재귀적으로 호출하게 될 수 있으며, 이는 의존성에 대해 재귀적으로 try_mark_green() 알고리즘으로 돌아올 수 있음을 의미합니다.
현실 세계: 영속화가 모든 것을 복잡하게 만드는 방식
위 절들에서는 증분 컴파일의 기반 알고리즘을 설명했지만, 컴파일러 프로세스는 작업을 마치면 종료되며 결과 캐시를 포함한 쿼리 컨텍스트도 함께 사라지므로, 다음 컴파일 세션이 이를 활용할 수 있도록 데이터를 디스크에 영속화해야 합니다. 이로 인해 완전히 새로운 구현상의 과제들이 생깁니다:
- 쿼리 결과 캐시는 디스크에 저장되므로, 변경 비교에 곧바로 사용할 수 없습니다.
- 이후의 컴파일 세션은 임의의 변경이 적용된 새 버전의 코드로 시작합니다. 전역적이고 순차적인 카운터로부터 생성되는 모든 종류의 ID와 인덱스(예:
NodeId,DefId등)가 밀려났을 수 있으며, 같은 숫자 ID와 인덱스가 새 컴파일 세션에서는 완전히 새로운 것을 가리킬 수 있으므로 디스크에 영속화된 결과를 더 이상 바로 사용할 수 없게 됩니다. - 디스크에 영속화하는 데에는 비용이 따르므로, 모든 사소한 정보를 컴파일 세션 사이에 실제로 캐시해서는 안 됩니다. 비용이 큰 (역)직렬화 단계를 거쳐야 하는 복잡한 것보다는 고정 크기의 단순한 데이터(plain-old-data)가 선호됩니다.
다음 절들에서는 컴파일러가 이러한 문제를 어떻게 해결하는지 설명합니다.
안정성의 문제: 컴파일 세션 사이의 간극 메우기
앞서 언급했듯이, 다양한 ID(DefId 등)는 컴파일되는 소스 코드의 내용에 따라 달라지는 방식으로 컴파일러에 의해 생성됩니다. ID 할당은 보통 결정적입니다. 즉, 완전히 같은 코드를 두 번 컴파일하면 같은 대상은 같은 ID를 갖게 됩니다. 하지만 무언가가 변경되면(예: 파일 중간에 함수가 추가되면) 어떤 것이든 이전과 같은 ID를 가진다는 보장이 없습니다.
그 결과 디스크 캐시의 데이터를 메모리에서 표현하는 것과 같은 방식으로 표현할 수 없습니다. 예를 들어 TyKind::FnDef(DefId, GenericArgsRef<'tcx>)와 같은 타입 정보를 (메모리에서처럼) 그대로 저장했는데, 새 컴파일 세션에서 그 안에 포함된 DefId가 다른 함수를 가리킨다면 문제가 생깁니다.
이 문제의 해결책은 컴파일 세션 사이에서도 유효하게 유지되는 ID의 “안정적인” 형태를 찾는 것입니다. 가장 중요한 경우인 DefId의 경우, 이것이 바로 이른바 DefPath입니다. 각 DefId에는 대응하는 DefPath가 있지만, DefPath는 숫자 ID 대신 식별된 항목까지의 경로(예: std::collections::HashMap)를 기반으로 합니다. 이러한 ID의 장점은 관련 없는 변경의 영향을 받지 않는다는 것입니다. 예를 들어, std::collections에 새 함수를 추가하더라도 std::collections::HashMap은 여전히 std::collections::HashMap입니다. DefPath는 소스 코드가 변경되어도 “안정적“이지만 DefId는 그렇지 않습니다.
또한 DefPath의 128비트 해시 값일 뿐인 DefPathHash도 있습니다. 두 가지는 동일한 정보를 담고 있으며, DefPathHash는 Copy이고 자기 완결적이어서 다루기가 더 간단하기 때문에 주로 DefPathHash를 사용합니다.
이러한 안정적인 식별자의 원칙은 디스크 캐시의 데이터가 소스 코드 변경에도 견고하도록 만드는 데 사용됩니다. DefId를 저장하는 대신 DefPathHash를 저장하며, 캐시에서 무언가를 역직렬화할 때 DefPathHash를 현재 컴파일 세션의 대응하는 DefId에 매핑합니다(이는 단순한 해시 테이블 조회일 뿐입니다).
자체 DefId를 가지지 않는 HIR 구성 요소를 식별하는 데 사용되는 HirId도 이러한 안정적인 ID 중 하나입니다. 이는 (개념적으로) DefPath와 LocalId의 쌍이며, 여기서 LocalId는 자신의 “소유자”(예: hir::Item) 내에서 무언가(예: hir::Expr)를 지역적으로 식별합니다. 소유자가 이리저리 옮겨지더라도 그 안의 LocalId는 여전히 동일합니다.
쿼리 결과의 변경 여부 확인: StableHash와 Fingerprint
빨강-초록 표시를 하려면 쿼리 결과가 이전 컴파일 세션에서의 결과와 비교하여 변경되었는지 확인해야 하는 경우가 많습니다. 하지만 여기에는 두 가지 성능 문제가 있습니다:
- 단지 비교를 하기 위해 디스크에서 이전 결과를 불러와야 하는 일은 피하고 싶습니다. 새 결과는 이미 계산했으며 그것을 사용할 것입니다. 또한 디스크에서 결과를 불러오면 사용될 가능성이 거의 없는 데이터로 인터너가 “오염“됩니다.
- 모든 결과를 하나하나 디스크 캐시에 저장하고 싶지는 않습니다. 예를 들어, 업스트림 크레이트에서 이미 사용할 수 있는 것을 디스크에 영속화하는 것은 헛된 노력입니다.
컴파일러는 이른바 Fingerprint를 사용하여 이러한 문제를 피합니다. 새 쿼리 결과가 계산될 때마다 쿼리 엔진은 그 결과의 128비트 해시 값을 계산합니다. 이 해시 값을 “쿼리 결과의 Fingerprint“라고 부릅니다. 해싱은 “안정적인 방식으로” 수행되며, 또 그렇게 수행되어야 합니다. 이는 컴파일 세션 사이에 변경될 수 있는 것(예: DefId)을 해싱할 때마다 그 대신 그것의 안정적인 대응물(예: 대응하는 DefPath)을 해싱한다는 뜻입니다. 이것이 바로 StableHash 인프라 전체가 존재하는 이유입니다. 이렇게 하면 서로 다른 두 컴파일 세션에서 계산된 Fingerprint도 여전히 비교할 수 있습니다.
다음 단계는 이러한 핑거프린트를 의존성 그래프와 함께 저장하는 것입니다. 핑거프린트는 복사할 바이트일 뿐이므로 이 작업은 비용이 적게 듭니다. 핑거프린트 전체 집합을 의존성 그래프와 함께 불러오는 것도 비용이 적게 듭니다.
이제 빨강-초록 표시 과정에서 결과가 변경되었는지 확인해야 하는 시점에 이르면, (이미 불러온) 이전 핑거프린트를 새 결과의 핑거프린트와 비교하기만 하면 됩니다.
이 접근 방식은 꽤 잘 작동하지만 결점이 없는 것은 아닙니다.
-
해시 충돌이 일어날 가능성이 조금 있습니다. 즉, 서로 다른 두 결과가 같은 핑거프린트를 가질 수 있으며, 이 경우 시스템은 결과가 바뀌지 않았다고 잘못 판단하여 갱신을 놓치게 됩니다.
저희는 품질 좋은 해시 함수와 128비트 폭의 해시 값을 사용하여 이 위험을 줄입니다. 이러한 조치 덕분에 해시 충돌의 실질적인 위험은 무시할 수 있는 수준입니다.
-
핑거프린트 계산은 비용이 상당히 큽니다. 이것이 증분 컴파일이 비증분 컴파일보다 느릴 수 있는 주된 이유입니다. 저희는 좋은, 따라서 비용이 큰 해시 함수를 쓸 수밖에 없으며, 해싱하는 동안 대상을 안정적인 등가물로 매핑해야 합니다.
두 DepGraph 이야기: 이전 그래프와 새 그래프
앞서 의존성 추적을 처음 설명할 때는 몇 가지 세부 사항을 대충 넘어갔는데, 실제로 구현하려고 하면 이 부분들이 금세 골칫거리가 됩니다. 특히 실제로는 두 개의 의존성 그래프를 다루고 있다는 사실을 놓치기 쉽습니다. 하나는 이전 컴파일 세션에서 만든 그래프이고, 다른 하나는 현재 컴파일 세션을 위해 만들고 있는 그래프입니다.
컴파일 세션이 시작되면 컴파일러는 이전 의존성 그래프를 변경할 수 없는 데이터로 메모리에 로드합니다. 그런 다음 쿼리가 호출되면, 먼저 그래프에서 해당 노드를 초록색으로 표시하려고 시도합니다. 이는 실제로는 현재 세션의 쿼리 키에 대응하는 이전 의존성 그래프의 노드를 초록색으로 표시하려 한다는 뜻입니다. 현재 쿼리 키와 이전 DepNode 사이의 이 매핑은 어떻게 할까요? 답은 이번에도 Fingerprint입니다. 의존성 그래프의 노드는 쿼리 키의 핑거프린트로 식별됩니다. 핑거프린트는 컴파일 세션이 바뀌어도 안정적이므로, 현재 세션에서 핑거프린트를 계산하면 이전 세션의 의존성 그래프에서 노드를 찾을 수 있습니다. 주어진 핑거프린트를 가진 노드를 찾지 못했다면, 그 쿼리 키가 이전 세션에는 아직 존재하지 않았던 무언가를 가리킨다는 뜻입니다.
이렇게 이전 의존성 그래프에서 dep-node를 찾았으면, 그 의존성(즉, 역시 이전 그래프에 있는 dep-node)을 조회하고 try-mark-green 알고리즘의 나머지를 계속 진행할 수 있습니다. 다음으로 흥미로운 일은 노드를 초록색으로 표시하는 데 성공했을 때 일어납니다. 이 시점에서 노드와 그 노드의 의존성으로 이어지는 간선을 이전 그래프에서 새 그래프로 복사합니다. 새 의존성 그래프는 일반적인 의존성 추적으로는 이 노드와 간선을 얻을 수 없기 때문에 이렇게 해야 합니다. 추적 시스템은 실제로 쿼리를 실행하는 동안에만 간선을 기록할 수 있습니다. 하지만 결과가 이미 캐시되어 있는데도 쿼리를 실행하는 것이야말로 저희가 피하고 싶은 일입니다.
컴파일 세션이 끝나면, 변경되지 않은 부분은 모두 이전 의존성 그래프에서 새 그래프로 복사되어 있고, 변경된 부분은 추적 시스템에 의해 새 그래프에 추가되어 있습니다. 이 시점에서 새 그래프는 쿼리 결과 캐시와 함께 디스크에 직렬화되며, 이후 컴파일 세션에서 이전 의존성 그래프 역할을 할 수 있습니다.
뭔가 잊지 않으셨습니까?: 캐시 승격
지금까지 설명한 시스템에는 다소 미묘한 속성이 있습니다. dep-node의 모든 입력이 green이면, 해당 쿼리 결과를 계산하거나 로드하지 않고도 dep-node 자체를 green으로 표시할 수 있다는 것입니다. 이 속성을 추이적으로 적용하다 보면, 다음 예와 같이 일부 중간 결과가 실제로는 디스크에서 한 번도 로드되지 않는 상황이 자주 생깁니다.
input(A) <-- intermediate_query(B) <-- leaf_query(C)
컴파일러는 어떤 출력 산출물을 생성하기 위해 leaf_query(C)의 값이 필요할 수 있습니다. leaf_query(C)를 초록색으로 표시할 수 있다면, 컴파일러는 디스크 캐시에서 그 결과를 로드합니다. 하지만 intermediate_query(B)의 결과는 한 번도 로드되지 않습니다. 그 결과, 컴파일러가 메모리에 있는 모든 쿼리 결과를 디스크에 기록하여 새 결과 캐시를 저장할 때 intermediate_query(B)는 메모리에 없으므로 새 결과 캐시에서 빠지게 됩니다.
이후에 intermediate_query(B)의 결과가 실제로 필요한 컴파일 세션이 또 있다면, 바로 직전까지 캐시에 완전히 유효한 결과가 있었는데도 그 결과를 다시 계산해야 합니다.
이런 일을 막기 위해 컴파일러는 “캐시 승격(cache promotion)“이라는 작업을 수행합니다. 새 결과 캐시를 내보내기 전에 모든 초록색 dep-node를 순회하면서 각 노드의 쿼리 결과가 메모리에 로드되어 있는지 확인합니다. 이렇게 하면 결과 캐시가 불필요하게 다시 줄어들지 않습니다.
증분 컴파일과 컴파일러 백엔드
LLVM이 관여하는 부분인 컴파일러 백엔드는 쿼리 시스템을 사용하지만, 백엔드 자체는 쿼리로 구현되어 있지 않습니다. 따라서 백엔드는 의존성 추적에 자동으로 참여하지 않습니다. 하지만 추적 시스템과 수동으로 통합하는 방법은 꽤 간단합니다. 컴파일러는 각 코드 생성 단위(CGU)의 초기 LLVM 버전을 생성할 때 어떤 쿼리가 호출되는지 추적하기만 하며, 그 결과 CGU마다 dep-node가 하나씩 생깁니다. 그런 다음 이후의 컴파일 세션에서 CGU의 dep-node를 초록색으로 표시하려고 시도합니다. 성공하면 디스크에 있는 해당 오브젝트 파일과 비트코드 파일이 여전히 유효하다는 것을 알 수 있습니다. 성공하지 못하면 CGU 전체를 다시 컴파일해야 합니다.
이는 일반 쿼리에 사용되는 것과 같은 접근 방식입니다. 주요 차이점은 다음과 같습니다.
-
LLVM 모듈은 불투명한 C++ 객체이기 때문에 핑거프린트를 쉽게 계산할 수 없다는 점,
-
여기서는 공통 결과 캐시 파일에 든 직렬화된 Rust 값 대신 비트코드 파일과 오브젝트 파일을 다루므로, 캐시된 값을 처리하는 로직이 일반 쿼리와 상당히 다르다는 점,
-
LLVM 관련 작업은 계산 시간과 메모리 소비 측면에서 비용이 매우 크기 때문에, 무엇을 언제 실행하고 무엇을 얼마나 오래 메모리에 유지할지를 엄격하게 제어해야 한다는 점입니다.
위의 모든 사항을 처리할 수 있는 범용 메커니즘으로 쿼리 시스템을 확장할 수도 있겠지만, 지금까지는 그로 인해 얻는 것보다 드는 수고가 더 클 것으로 보였습니다.
쿼리 수정자
FIXME:
rustc_middle::query::modifiers를 쿼리 수정자 문서의 본거지로 삼고, 다른 유용한 수정자 문서가 여전히 정확한지 확인한 뒤 모두 그곳으로 옮기십시오.
쿼리 시스템에서는 쿼리에 수정자를 적용할 수 있습니다. 이러한 수정자는 증분 컴파일과 관련하여 시스템이 쿼리를 다루는 방식의 특정 측면에 영향을 줍니다.
-
eval_always-eval_always속성이 있는 쿼리는 증분 컴파일 중에 무조건 다시 실행됩니다. 즉, 시스템은 그 쿼리의 dep-node를 초록색으로 표시하려고 시도조차 하지 않습니다. 이 속성의 용도는 두 가지입니다.-
eval_always쿼리는 파일, 전역 상태 등에서 입력을 읽을 수 있습니다. 또한 파일에 쓰기, 전역 상태 변경 같은 부수 효과를 일으킬 수도 있습니다. -
어떤 쿼리는 결과가 소스 코드 전체에 의존하기 때문에 다시 평가될 가능성이 매우 높습니다. 이 경우 시스템이 애초에 의존성 기록을 건너뛸 수 있으므로
eval_always를 최적화 수단으로 사용할 수 있습니다.
-
-
no_force- 쿼리의 키 타입이 복원 가능하더라도 이 쿼리의 dep 노드를 절대 “강제(force)“하지 않습니다. -
no_hash- 쿼리에no_hash를 적용하면 시스템이 쿼리 결과의 핑거프린트를 계산하지 않습니다. 이로 인해 두 가지 결과가 생깁니다.-
핑거프린트 계산은 특히 크고 복잡한 값에서 비용이 크기 때문에, 이를 계산하지 않으면 시간을 상당히 절약할 수 있습니다.
-
핑거프린트가 없으면 시스템은 쿼리 결과가 변경되었다고 무조건 가정해야 합니다. 따라서
no_hash쿼리에 의존하는 것은 모두 항상 다시 실행됩니다.
쿼리에
no_hash를 사용하는 것은 두 가지 경우에 의미가 있습니다.-
첫째, 쿼리의 입력 중 하나가 변경될 때마다 결과도 변경될 가능성이 매우 높은 경우입니다. 예를 들어
|a, b, c| -> (a * b * c)와 같은 함수가 이에 해당합니다. 이런 경우 입력 중 하나가 빨간색이면 쿼리를 다시 계산해도 항상 빨간색 노드가 나오므로, 그 수고를 덜고 곧바로 빨간색으로 간주해도 됩니다. 반례로는|a| -> (a == 42)와 같은 함수를 들 수 있습니다. 이 함수는a가 변경되더라도 대부분의 경우 결과가 변하지 않습니다. -
둘째, 쿼리 결과가 크고 단일한(monolithic) 컬렉션(예:
index_hir)이고, 그 컬렉션에서 값을 읽어 오는 “프로젝션 쿼리”(예:hir_owner)가 있는 경우입니다. 이런 경우 큰 컬렉션은 위의 조건을 충족할 가능성이 높으며(입력이 하나라도 변경되면 컬렉션 전체를 다시 계산해야 함), 프로젝션 쿼리의 결과는 어차피 해시됩니다. 컬렉션 쿼리까지 해시한다면 사실상 같은 데이터를 두 번 해시하는 셈입니다. 한 번은 컬렉션을 해시할 때, 또 한 번은 모든 프로젝션 쿼리 결과를 해시할 때입니다.no_hash를 사용하면 이러한 중복을 피할 수 있으며, 프로젝션 쿼리는 “방화벽” 역할을 하여 자신에게 의존하는 것들을 무조건 빨간색인no_hash노드로부터 보호합니다.
-
-
cache_on_disk- 쿼리의 반환 값이 디스크에 캐시되며, 해당 의존성 노드가 초록색이면 이후 세션에서 불러올 수 있습니다.separate_provide_extern수식자도 함께 지정되어 있으면 값은 “로컬” 키에 대해서만 디스크에 캐시됩니다. 외부 크레이트의 값은 대신 크레이트 메타데이터에서 불러올 수 있어야 하기 때문입니다. -
feedable- 이 쿼리는 실제로는 함수가 아니라 자체 아레나(arena) 타입입니다. 이는 아레나를 선언하고, 저장할 정보를 컴파일의 나중 시점(예를 들어 실제로Crate객체를 사용할 수 있게 되었을 때)에 “공급(feed)“한 다음, 다른 크레이트와 마찬가지로 가져오기 위한 것입니다. 따라서 이러한 쿼리에 대한 함수 정의는 존재하지 않습니다.
프로젝션 쿼리 패턴
흥미롭게도 eval_always와 no_hash는 이른바 “프로젝션 쿼리” 패턴에서 함께 사용될 수 있습니다. 컴파일러 입력 전체에 의존하는 쿼리 하나(예: 인덱싱된 HIR)와, 이 단일한 값에서 개별 값을 추출하는 또 다른 쿼리(예: 특정 DefId를 가진 HIR 항목)가 있는 경우가 많습니다. 이러한 프로젝션 쿼리를 사용하면 변경 전파를 막는 “방화벽“을 구축할 수 있습니다. 단일 쿼리의 결과가 변경되더라도(그럴 가능성은 매우 높습니다) 작은 프로젝션들은 대부분 여전히 초록색으로 표시될 수 있기 때문입니다.
+------------+
| | +---------------+ +--------+
| | <---------| projection(x) | <---------| foo(a) |
| | +---------------+ +--------+
| |
| monolithic | +---------------+ +--------+
| query | <---------| projection(y) | <---------| bar(b) |
| | +---------------+ +--------+
| |
| | +---------------+ +--------+
| | <---------| projection(z) | <---------| baz(c) |
| | +---------------+ +--------+
+------------+
monolithic_query의 결과가 변경되어 projection(x)의 결과도 변경되었다고, 즉 두 dep-node가 모두 red로 표시된다고 가정해 봅시다. 그러면 foo(a)는 다시 실행되어야 하지만, bar(b)와 baz(c)는 초록색으로 표시될 수 있습니다. 그러나 foo, bar, baz가 monolithic_query에 직접 의존했다면 이들 모두를 다시 평가해야 했을 것입니다.
이 패턴은 eval_always와 no_hash 없이도 작동하지만, 이 두 수식자를 사용하면 불필요한 오버헤드를 피할 수 있습니다. 단일 쿼리가 컴파일러 입력의 사소한 수정에도 변경될 가능성이 높다면, 이를 eval_always로 표시하여 의존성 추적 비용을 없애는 것이 합리적입니다. 또한 단일 쿼리를 no_hash로 표시하는 것은 언제나 합리적입니다. 가능한 한 많은 것을 초록색으로 유지하는 일은 프로젝션이 맡아 주기 때문입니다.
현재 시스템의 한계
아직 개선할 수 있는 점이 많습니다.
디스크 내 자료 구조의 증분성
현재 시스템은 디스크상 캐시와 의존성 그래프를 제자리에서(in-place) 갱신할 수 없습니다. 대신 컴파일 세션마다 각 파일을 통째로 다시 써야 합니다. 이로 인한 오버헤드는 전체 컴파일 시간의 몇 퍼센트 정도입니다.
불필요한 데이터 의존성
쿼리 결과로 사용되는 자료 구조는 의존성 그래프에서 엣지를 제거하는 방식으로 분해될 수 있습니다. 특히 “span” 정보는 매우 자주 바뀌므로 이를 쿼리 결과에 포함하면 그 결과를 재사용하지 못할 가능성이 높아집니다. 자세한 내용은 https://github.com/rust-lang/rust/issues/47389를 참고하십시오.
의존성 디버깅 및 테스트
의존성 그래프 테스트하기
의존성 그래프에 대한 테스트를 작성하는 방법은 여러 가지가 있습니다. 가장 간단한 메커니즘은 #[rustc_if_this_changed] 및 #[rustc_then_this_would_need] 어노테이션입니다. 이들은 ui 테스트에서 예상되는 경로 집합이 의존성 그래프에 존재하는지 테스트하는 데 사용됩니다.
예시로 tests/ui/dep-graph/dep-graph-caller-callee.rs 또는 아래의 테스트를 참고하십시오.
#[rustc_if_this_changed]
fn foo() { }
#[rustc_then_this_would_need(TypeckTables)] //~ ERROR OK
fn bar() { foo(); }
이는 다음과 같이 읽어야 합니다.
이것(
foo)이 변경되면, 이것(즉,bar)의 TypeckTables도 변경되어야 합니다.
기술적으로는, 이 줄과 연관된 문자열 “OK“를 테스트가 stderr에 출력할 것으로 기대되는 방식으로 동작합니다.
다음 줄을 추가할 수도 있습니다.
#[rustc_then_this_would_need(TypeckTables)] //~ ERROR no path
fn baz() { }
그 의미는 다음과 같습니다.
foo가 변경되더라도baz의 TypeckTables는 변경될 필요가 없습니다. 매크로는 오류를 발생시켜야 하며, 오류 메시지에는 “no path”(경로 없음)가 포함되어야 합니다.
//~ ERROR OK는 테스트 대상인 Rust 코드의 관점에서는 주석이지만, 테스트 자체의 관점에서는 의미를 가진다는 점을 기억하십시오.
의존성 그래프 디버깅하기
그래프 덤프하기
컴파일러는 디버깅을 돕기 위해 의존성 그래프를 덤프하는 기능도 제공합니다. 이를 위해서는 -Z dump-dep-graph 플래그를 전달하십시오. 그래프는 현재 디렉터리의 dep_graph.{txt,dot}에 덤프됩니다. RUST_DEP_GRAPH 환경 변수로 파일 이름을 재정의할 수 있습니다.
하지만 전체 의존성 그래프는 너무 방대해서 그다지 도움이 되지 않는 경우가 많습니다. 따라서 컴파일러는 그래프를 필터링하는 기능도 제공합니다. 다음 세 가지 방법으로 필터링할 수 있습니다.
- 특정 노드 집합(보통 단일 노드)에서 시작하는 모든 간선.
- 특정 노드 집합에 도달하는 모든 간선.
- 주어진 시작 노드와 끝 노드 사이에 있는 모든 간선.
필터링하려면 RUST_DEP_GRAPH_FILTER 환경 변수를 사용하십시오. 이 변수는 다음 중 하나의 형태여야 합니다.
source_filter // nodes originating from source_filter
-> target_filter // nodes that can reach target_filter
source_filter -> target_filter // nodes in between source_filter and target_filter
source_filter와 target_filter는 &로 구분된 문자열 목록입니다. 노드의 레이블에 해당 문자열이 모두 나타나면 그 노드는 필터와 일치하는 것으로 간주됩니다. 예를 들어 다음과 같이 작성하면
RUST_DEP_GRAPH_FILTER='-> TypeckTables'
모든 TypeckTables 노드의 선행 노드를 선택합니다. 하지만 보통은 특정 함수의 TypeckTables 노드가 필요하므로 다음과 같이 작성할 수 있습니다.
RUST_DEP_GRAPH_FILTER='-> TypeckTables & bar'
이렇게 하면 이름에 bar가 들어간 함수의 TypeckTables 노드의 선행 노드만 선택됩니다.
foo를 변경하면 bar를 다시 타입 검사해야 하는데, 그럴 필요가 없다고 생각할 때가 있을 수 있습니다. 그런 경우에는 다음과 같이 할 수 있습니다.
RUST_DEP_GRAPH_FILTER='Hir & foo -> TypeckTables & bar'
이렇게 하면 Hir(foo)에서 TypeckTables(bar)로 이어지는 모든 노드가 덤프되며, 이를 통해 (바라건대) 잘못된 간선이 어디서 생겼는지 확인할 수 있습니다.
잘못된 간선 추적하기
의존성 그래프를 덤프한 뒤, 존재해서는 안 되는 경로를 발견했지만 그 경로가 어떻게 생겼는지 잘 모를 때가 있습니다. 컴파일러가 디버그 단언을 켠 상태로 빌드되었다면, 컴파일러가 이를 추적하는 데 도움을 줄 수 있습니다. RUST_FORBID_DEP_GRAPH_EDGE 환경 변수를 필터로 설정하기만 하면 됩니다. 의존성 그래프에서 생성되는 모든 간선이 그 필터와 대조되며, 일치하면 bug!가 보고되므로 백트레이스(RUST_BACKTRACE=1)를 쉽게 확인할 수 있습니다.
이 필터들의 구문은 이전 절에서 설명한 것과 같습니다. 다만 앞 절과 달리 이 필터는 각 간선에 적용되며, 그래프의 더 긴 경로는 처리하지 않는다는 점에 유의하십시오.
예시:
foo의 Hir에서 bar의 타입 검사로 이어지는 경로가 있는데, 그런 경로가 있어서는 안 된다고 생각한다고 합시다. 앞 절에서 설명한 대로 의존성 그래프를 덤프하고 dep-graph.txt를 열면 다음과 같은 내용이 보입니다.
Hir(foo) -> Collect(bar)
Collect(bar) -> TypeckTables(bar)
첫 번째 간선이 의심스러워 보입니다. 그래서 RUST_FORBID_DEP_GRAPH_EDGE를 Hir&foo -> Collect&bar로 설정하고 다시 실행한 다음 백트레이스를 살펴봅니다. 짜잔, 버그가 고쳐졌습니다!
Salsa의 작동 방식
이 장은 Niko Matsakis가 Salsa에 관한 이 영상에서 설명한 내용을 바탕으로 합니다. 더 자세히 알아보려면 역시 Niko Matsakis가 진행한 Salsa In More Depth를 시청해 보십시오.
2022년 11월 기준으로, Salsa는 (여러 가지 중에서도) rustc의 쿼리 시스템에서 영감을 받았지만 rustc에서 직접 사용되지는 않습니다. Salsa는 Rust 트레이트 시스템의 구현체인 [chalk]에서 _실제로_ 사용되고 있으며, Rust용 언어 서버 프로토콜의 공식 구현체인 [`rust-analyzer`]에서는 광범위하게 사용되지만, 이를 컴파일러에 통합하려는 중장기적인 구체적 계획은 없습니다.
Salsa란 무엇입니까?
Salsa는 점진적 재계산(incremental recomputation)을 위한 라이브러리입니다. 즉, 과거에 이미 수행된 계산을 재사용하여 이후 계산의 효율성을 높일 수 있게 해 줍니다.
Salsa의 목표는 다음과 같습니다:
- 이 기능을 자동화된 방식으로 제공하여, 과거 계산의 재사용이 라이브러리에 의해 자동으로 이루어지도록 합니다.
- 이를 “건전한(sound)”, 즉 “올바른” 방식으로 수행하여, 처음부터 계산한 것과 동일한 결과를 얻도록 합니다.
Salsa의 실제 모델은 훨씬 풍부하여, 다양한 종류의 입력과 여러 가지 다른 출력을 허용합니다. 예를 들어, Salsa를 IDE와 통합한다면 입력은 매니페스트(Cargo.toml, rust-toolchain.toml), 전체 소스 파일(foo.rs), 스니펫 등이 될 수 있습니다. 이러한 통합의 출력은 바이너리 실행 파일부터 린트, 타입(예를 들어 사용자가 특정 변수를 선택하고 그 타입을 확인하려는 경우), 자동 완성 등에 이르기까지 다양할 수 있습니다.
어떻게 작동합니까?
Salsa가 가장 먼저 해야 하는 일은 계산된 것이 아니라 입력으로 주어진 “기본 입력(base input)“을 식별하는 것입니다.
그다음 Salsa는 중간 단계의 “파생(derived)” 값도 식별해야 합니다. 파생 값은 라이브러리가 생성하는 것이지만, 각 파생 값마다 그 값을 계산하는 “순수(pure)” 함수가 존재합니다.
예를 들어, ast(x: Path) -> AST라는 함수가 있을 수 있습니다. 생성된 추상 구문 트리(AST)는 최종 값이 아니라, 라이브러리가 계산에 사용하는 중간 값입니다.
즉, 라이브러리로 계산을 수행하려고 하면 Salsa는 다양한 파생 값을 계산하고, 최종적으로 입력을 읽어 요청된 계산의 결과를 생성합니다.
계산 과정에서 Salsa는 어떤 입력에 접근했는지, 그리고 어떤 값이 파생되었는지를 추적합니다. 이 정보는 입력이 변경되었을 때 어떤 일이 일어날지, 즉 파생 값이 여전히 유효한지를 판단하는 데 사용됩니다.
그렇다고 해서 입력의 하류(downstream)에 있는 모든 계산을 반드시 검사한다는 뜻은 아니며, 그렇게 하면 비용이 많이 들 수 있습니다. Salsa는 변경되지 않은 계산을 찾을 때까지만 각 하류 계산을 검사하면 됩니다. 그 시점부터는 다른 파생 계산을 검사하지 않는데, 그 계산들은 변경될 필요가 없기 때문입니다.
이를 노드로 이루어진 그래프로 생각하면 이해하는 데 도움이 됩니다. 각 파생 값은 다른 값에 의존성을 가지며, 그 값들 자체도 기본 값이거나 파생 값일 수 있습니다. 기본 값에는 의존성이 없습니다.
I <- A <- C ...
|
J <- B <--+
입력 I가 변경되면 파생 값 A가 변경될 수 있습니다. I, A, 또는 A나 I에서 파생된 어떤 값에도 의존하지 않는 파생 값 B는 변경 대상이 아닙니다. 따라서 Salsa는 과거에 B에 대해 수행한 계산을 다시 계산할 필요 없이 재사용할 수 있습니다.
계산이 조기에 종료될 수도 있습니다. 앞과 같은 그래프를 유지한 채, 입력 I는 어떤 식으로든 변경되었지만(입력 J는 변경되지 않았음) A를 다시 계산해 보니 A가 이전 계산과 달라지지 않았다고 가정해 봅시다. 이 경우 “조기 종료“가 일어납니다. C의 직접 입력인 A와 B가 모두 변경되지 않았으므로 C가 변경되어야 하는지 확인할 필요가 없기 때문입니다.
Salsa의 핵심 개념
쿼리
쿼리는 Salsa가 계산 과정에서 접근할 수 있는 어떤 값입니다. 각 쿼리는 여러 개의 키(0개부터 여러 개까지)를 가질 수 있으며, 함수와 마찬가지로 모든 쿼리에는 결과가 있습니다. 0-key 쿼리는 “입력” 쿼리라고 부릅니다.
데이터베이스
데이터베이스는 기본적으로 전체 계산의 컨텍스트이며, Salsa의 내부 상태, 각 쿼리의 모든 중간값, 그리고 계산에 필요할 수 있는 그 밖의 모든 것을 저장하기 위한 것입니다. 데이터베이스를 빌드하려면 먼저 라이브러리가 수행할 모든 쿼리를 알아야 하지만, 이 쿼리들이 같은 곳에 지정될 필요는 없습니다.
데이터베이스가 구성된 후에는 함수와 매우 비슷한 쿼리를 통해 데이터베이스에 접근할 수 있습니다. 각 쿼리의 결과는 데이터베이스에 저장되므로, 쿼리를 N번 호출하면 쿼리를 다시 계산하지 않고 N개의 복제된 결과를 반환합니다(재계산이 필요할 만큼 입력이 변경된 경우는 제외합니다).
각 입력 쿼리(0-key)마다 “set” 메서드가 생성되며, 사용자는 이를 통해 해당 쿼리의 출력을 변경하고 이전에 메모이제이션된 값이 무효화될 수 있도록 할 수 있습니다.
쿼리 그룹
쿼리 그룹은 하나의 단위로 함께 정의된 쿼리들의 집합입니다. 데이터베이스는 쿼리 그룹들을 결합하여 구성됩니다. 쿼리 그룹은 “Salsa 모듈“과 비슷합니다.
쿼리 그룹 안의 쿼리 집합은 단지 트레이트 안의 메서드 집합일 뿐입니다.
쿼리 그룹을 만들려면 특정 속성(#[salsa::query_group(...)])이 붙은 트레이트를 만들어야 합니다.
해당 속성에는 인자도 함께 제공해야 합니다. Salsa는 이 인자를 사용해 나중에 데이터베이스를 생성할 때 쓸 struct를 만듭니다.
입력 쿼리 그룹 예시:
/// This attribute will process this tree, produce this tree as output, and produce
/// a bunch of intermediate stuff that Salsa also uses. One of these things is a
/// "StorageStruct", whose name we have specified in the attribute.
///
/// This query group is a bunch of **input** queries, that do not rely on any
/// derived input.
#[salsa::query_group(InputsStorage)]
pub trait Inputs {
/// This attribute (`#[salsa::input]`) indicates that this query is a base
/// input, therefore `set_manifest` is going to be auto-generated
#[salsa::input]
fn manifest(&self) -> Manifest;
#[salsa::input]
fn source_text(&self, name: String) -> String;
}
파생 쿼리 그룹을 만들려면, 다음 예시와 같이 이 쿼리 그룹이 의존하는 다른 쿼리 그룹들을 슈퍼트레이트로 지정해야 합니다.
/// This query group is going to contain queries that depend on derived values.
/// A query group can access another query group's queries by specifying the
/// dependency as a supertrait. Query groups can be stacked as much as needed using
/// that pattern.
#[salsa::query_group(ParserStorage)]
pub trait Parser: Inputs {
/// This query `ast` is not an input query, it's a derived query this means
/// that a definition is necessary.
fn ast(&self, name: String) -> String;
}
파생 쿼리를 만들 때는 해당 쿼리의 구현을 트레이트 바깥에 정의해야 합니다. 이 정의는 다른 키들 외에도 데이터베이스 매개변수를 impl Trait(또는 dyn Trait)로 받아야 하며, 여기서 트레이트는 해당 정의가 속한 쿼리 그룹입니다.
/// This is going to be the definition of the `ast` query in the `Parser` trait.
/// So, when the query `ast` is invoked, and it needs to be recomputed, Salsa is
/// going to call this function and it's going to give it the database as `impl
/// Parser`. The function doesn't need to be aware of all the queries of all the
/// query groups
fn ast(db: &impl Parser, name: String) -> String {
//! Note, `impl Parser` is used here but `dyn Parser` works just as well
/* code */
///By passing an `impl Parser`, this is allowed
let source_text = db.input_file(name);
/* do the actual parsing */
return ast;
}
최종적으로 모든 쿼리 그룹을 정의한 후에는 struct를 선언하여 데이터베이스를 생성할 수 있습니다.
어떤 쿼리 그룹이 데이터베이스에 포함될지 지정하려면 attribute(#[salsa::database(...)])를 추가해야 합니다. 해당 attribute의 인자는 identifiers의 목록이며, 쿼리 그룹의 저장소를 지정합니다.
///This attribute specifies which query groups are going to be in the database
#[salsa::database(InputsStorage, ParserStorage)]
#[derive(Default)] //optional!
struct MyDatabase {
///You also need this one field
runtime : salsa::Runtime<MyDatabase>,
}
///And this trait has to be implemented
impl salsa::Database for MyDatabase {
fn salsa_runtime(&self) -> &salsa::Runtime<MyDatabase> {
&self.runtime
}
}
사용 예시:
fn main() {
let db = MyDatabase::default();
db.set_manifest(...);
db.set_source_text(...);
loop {
db.ast(...); //will reuse results
db.set_source_text(...);
}
}
rustc의 메모리 관리
일반적으로 rustc는 메모리 관리 방식에 상당히 주의를 기울입니다. 컴파일러는 컴파일 전반에 걸쳐 아주 많은 자료 구조를 할당하며, 주의하지 않으면 이 작업에 많은 시간과 공간이 소요됩니다.
컴파일러가 이를 관리하는 주요 방법 중 하나는 아레나와 인터닝을 사용하는 것입니다.
아레나와 인터닝
컴파일 중에는 매우 많은 자료 구조가 생성되므로, 성능상의 이유로 이들을 전역 메모리 풀에서 할당합니다. 각 데이터 구조는 수명이 긴 아레나에서 한 번만 할당됩니다. 이를 아레나 할당이라고 합니다. 이 시스템은 메모리 할당 및 해제 횟수를 줄여 줍니다. 또한 타입의 동등성을 쉽게 비교할 수 있게 해 줍니다(타입에 대한 자세한 내용은 여기를 참고하십시오). 인터닝된 각 타입 X에 대해 X에 대한 PartialEq를 구현해 두었으므로 포인터만 비교하면 됩니다. CtxtInterners 타입에는 인터닝된 타입들의 여러 맵과 아레나 자체가 들어 있습니다.
예시: ty::TyKind
컴파일러에서 타입을 나타내는 ty::TyKind를 예로 들어 보겠습니다(자세한 내용은 여기에서 읽을 수 있습니다). 컴파일러는 타입을 생성할 때마다 버퍼에서 단순히 할당하지 않습니다. 대신 해당 타입이 이미 생성되었는지 확인합니다. 이미 생성되었다면 이전에 받았던 것과 같은 포인터를 그대로 받고, 그렇지 않다면 새 포인터를 만듭니다. 이 방식에서는 두 타입이 같은지 알고 싶을 때 포인터만 비교하면 되므로 효율적입니다. ty::TyKind는 절대 스택에 생성해서는 안 되며, 그렇게 하면 사용할 수 없게 됩니다. 이들은 항상 이 아레나에서 할당하고, 고유하도록 항상 인터닝합니다.
컴파일을 시작할 때 버퍼를 하나 만들고, 타입을 할당해야 할 때마다 이 메모리 버퍼의 일부를 사용합니다. 공간이 부족해지면 버퍼를 하나 더 가져옵니다. 이 버퍼의 라이프타임은 'tcx입니다. 타입들은 이 라이프타임에 묶여 있으므로, 컴파일이 끝나면 해당 버퍼와 관련된 모든 메모리가 해제되고 'tcx 참조는 유효하지 않게 됩니다.
타입 외에도 할당할 수 있는 아레나 할당 자료 구조가 여러 가지 더 있으며, 이들은 이 모듈에서 찾을 수 있습니다. 다음은 몇 가지 예시입니다:
GenericArgs는mk_args로 할당됩니다 – 이는 타입의 슬라이스를 인터닝하며, 제네릭 인자에 대입될 값을 지정하는 데 자주 사용됩니다(예:HashMap<i32, u32>는 슬라이스&'tcx [tcx.types.i32, tcx.types.u32]로 표현됩니다).TraitRef는 일반적으로 값으로 전달됩니다 – 트레이트 참조는i32: Display처럼 트레이트에 대한 참조와 그 트레이트의 여러 타입 매개변수(Self포함)로 구성됩니다(여기서 def-id는Display트레이트를 참조하고, args에는i32가 들어 있습니다).def-id는AdtDef and DefId절에서 정의되고 자세히 다뤄진다는 점에 유의하십시오.Predicate는 트레이트 시스템이 증명해야 하는 대상을 정의합니다(traits 모듈을 참고하십시오).
tcx와 그 라이프타임 사용 방식
타이핑 컨텍스트(tcx)는 컴파일러의 핵심 자료 구조입니다. 이는 온갖 종류의 쿼리를 수행하는 데 사용하는 컨텍스트입니다. struct TyCtxt는 이 공유 컨텍스트에 대한 참조를 정의합니다:
tcx: TyCtxt<'tcx>
// ----
// |
// arena lifetime
보시다시피 TyCtxt 타입은 라이프타임 매개변수를 받습니다. 'tcx와 같은 라이프타임이 붙은 참조를 보면, 그것이 아레나에 할당된 데이터(또는 어쨌든 아레나만큼 오래 살아 있는 데이터)를 가리킨다는 것을 알 수 있습니다.
라이프타임에 관한 참고 사항
Rust 컴파일러는 큰 자료 구조(예: 추상 구문 트리(AST), 고수준 중간 표현(HIR), 타입 시스템)를 많이 포함하는 상당히 큰 프로그램이므로, 불필요한 메모리 사용을 최소화하기 위해 아레나와 참조에 크게 의존합니다. 이는 사람들이 컴파일러에 연결하는 방식(즉, 드라이버)에서도 드러나는데, 더 Rust다운 “pull” 방식(Iterator 트레이트를 떠올려 보십시오) 대신 “push” 방식의 API(콜백)를 선호합니다.
컴파일러 전반에서 스레드 로컬 저장소와 인터닝을 많이 사용하여 중복을 줄이는 동시에, 곳곳에 퍼져 있는 많은 라이프타임으로 인한 여러 사용성 문제도 방지합니다. rustc_middle::ty::tls 모듈은 이러한 스레드 로컬에 접근하는 데 사용되지만, 이 모듈을 직접 다뤄야 할 일은 거의 없을 것입니다.
rustc의 직렬화
rustc는 컴파일 중에 다양한 데이터를 직렬화하고 역직렬화해야 합니다. 구체적으로는 다음과 같습니다:
- 주로 쿼리 출력으로 구성된 “크레이트 메타데이터“는 라이브러리 크레이트를 컴파일할 때 출력되는
rlib및rmeta파일에 바이너리 형식으로 직렬화됩니다. 이후 이rlib및rmeta파일은 해당 라이브러리에 의존하는 크레이트에 의해 역직렬화됩니다. - 일부 쿼리 출력은 증분 컴파일 결과를 영속화하기 위해 바이너리 형식으로 직렬화됩니다.
CrateInfo는-Z no-link플래그를 사용하면JSON으로 직렬화되고,-Z link-only플래그를 사용하면JSON에서 역직렬화됩니다.
Encodable 및 Decodable 트레이트
rustc_serialize 크레이트는 직렬화할 수 있는 타입을 위한 두 가지 트레이트를 정의합니다:
pub trait Encodable<S: Encoder> {
fn encode(&self, s: &mut S) -> Result<(), S::Error>;
}
pub trait Decodable<D: Decoder>: Sized {
fn decode(d: &mut D) -> Result<Self, D::Error>;
}
또한 정수 타입, 부동소수점 타입, bool, char, str 등 흔히 쓰이는 여러 표준 라이브러리 기본 타입에 대한 이 트레이트들의 구현도 정의합니다.
이러한 타입으로 구성된 타입의 경우, Encodable과 Decodable은 보통 derive로 구현됩니다. 이 derive는 역직렬화를 구조체나 열거형의 필드로 전달하는 구현을 생성합니다. 구조체의 경우 해당 impl은 대략 다음과 같습니다.
#![feature(rustc_private)]
extern crate rustc_serialize;
use rustc_serialize::{Decodable, Decoder, Encodable, Encoder};
struct MyStruct {
int: u32,
float: f32,
}
impl<E: Encoder> Encodable<E> for MyStruct {
fn encode(&self, s: &mut E) -> Result<(), E::Error> {
s.emit_struct("MyStruct", 2, |s| {
s.emit_struct_field("int", 0, |s| self.int.encode(s))?;
s.emit_struct_field("float", 1, |s| self.float.encode(s))
})
}
}
impl<D: Decoder> Decodable<D> for MyStruct {
fn decode(s: &mut D) -> Result<MyStruct, D::Error> {
s.read_struct("MyStruct", 2, |d| {
let int = d.read_struct_field("int", 0, Decodable::decode)?;
let float = d.read_struct_field("float", 1, Decodable::decode)?;
Ok(MyStruct { int, float })
})
}
}
아레나 할당 타입의 인코딩과 디코딩
rustc에는 아레나 할당 타입이 많이 있습니다. 이러한 타입은 할당될 아레나에 접근하지 않고서는 역직렬화할 수 없습니다. TyDecoder와 TyEncoder 트레이트는 TyCtxt에 접근할 수 있게 해 주는 Decoder와 Encoder의 하위 트레이트입니다.
따라서 arena 할당 타입을 포함하는 타입은 자신의 Encodable 및 Decodable 구현의 타입 매개변수를 이 트레이트들로 한정할 수 있습니다. 예를 들면 다음과 같습니다.
impl<'tcx, D: TyDecoder<'tcx>> Decodable<D> for MyStruct<'tcx> {
/* ... */
}
TyEncodable 및 TyDecodable derive 매크로는 이러한 구현으로 확장됩니다.
실제 arena 할당 타입을 디코딩하는 것은 더 어렵습니다. 고아 규칙 때문에 일부 구현을 작성할 수 없기 때문입니다. 이 문제를 우회하기 위해 rustc_middle에 RefDecodable 트레이트가 정의되어 있습니다. 이 트레이트는 어떤 타입에 대해서든 구현할 수 있습니다. TyDecodable 매크로는 참조를 디코딩하기 위해 RefDecodable을 호출하지만, 여러 제네릭 코드에서는 타입이 특정 디코더에 대해 실제로 Decodable이어야 합니다.
인턴된 타입의 경우 RefDecodable을 직접 구현하는 대신 ty::Predicate처럼 뉴타입 래퍼를 사용하고 Encodable과 Decodable을 직접 구현하는 편이 더 간단할 수 있습니다.
Derive 매크로
rustc_macros 크레이트는 Decodable과 Encodable 구현을 돕는 다양한 derive를 정의합니다.
Encodable및Decodable매크로는 모든Encoders와Decoders에 적용되는 구현을 생성합니다. 이 매크로들은rustc_middle에 의존하지 않는 크레이트나,TyEncoder를 구현하지 않는 타입으로 직렬화해야 하는 크레이트에서 사용해야 합니다.MetadataEncodable은rustc_metadata::rmeta::encoder::EncodeContext로만 디코딩할 수 있는 구현을 생성합니다.BlobDecodable과LazyDecodable은MetadataEncodable에 대응하는 디코딩 측 매크로입니다. 이 매크로들은rustc_metadata::rmeta의 메타데이터 블롭 디코더로 디코딩하는 구현을 생성합니다. 타입에 지연(lazy) 메타데이터 핸들이 없으면BlobDecodable을, 있으면LazyDecodable을 사용하십시오.TyEncodable과TyDecodable은 모든TyEncoder또는TyDecoder에 적용되는 구현을 생성합니다. 이들은 크레이트 메타데이터 및/또는 증분 캐시에서만 직렬화되는 타입에 사용해야 하며,rustc_middle에 있는 직렬화 가능한 타입 대부분이 여기에 해당합니다.
축약 표현
Ty는 깊게 재귀적일 수 있으므로, 각 Ty를 단순하게 인코딩하면 크레이트 메타데이터가 매우 커집니다. 이를 처리하기 위해 각 TyEncoder는 출력에서 타입을 직렬화한 위치의 캐시를 가지고 있습니다. 인코딩하려는 타입이 캐시에 있으면 평소처럼 타입을 직렬화하는 대신, 작성 중인 파일 내의 바이트 오프셋을 인코딩합니다. ty::Predicate에도 비슷한 방식이 사용됩니다.
LazyValue<T>
크레이트 메타데이터는 TyCtxt<'tcx>가 생성되기 전에 처음 로드되므로, 일부 역직렬화는 메타데이터의 초기 로드 이후로 미뤄야 합니다. LazyValue<T> 타입은 T가 직렬화된 크레이트 메타데이터 내의 (상대) 오프셋을 감쌉니다. 몇 가지 변형으로 LazyArray<T>와 LazyTable<I, T>도 있습니다.
LazyArray<[T]>와 LazyTable<I, T> 타입은 Lazy<Vec<T>>와 Lazy<HashMap<I, T>>에 비해 다음과 같은 기능을 더 제공합니다:
- 먼저
Vec<T>로 수집하지 않고도Iterator에서LazyArray<T>를 직접 인코딩할 수 있습니다. LazyTable<I, T>를 인덱싱할 때 읽고 있는 항목 외의 다른 항목은 디코딩할 필요가 없습니다.
참고: LazyValue<T>는 처음 역직렬화된 후 그 값을 캐시하지 않습니다. 대신 쿼리 시스템 자체가 이러한 결과를 캐싱하는 주된 방법입니다.
특수화
몇몇 타입, 특히 DefId는 Encoder마다 서로 다른 구현이 필요합니다. 현재 이는 임시방편적인 특수화로 처리됩니다. 예를 들어 DefId에는 Encodable<E>의 default 구현과 Encodable<CacheEncoder>에 대한 특수화된 구현이 있습니다.
병렬 컴파일
- 코드 생성 부분은 기본적으로 동시에 실행됩니다.
-C codegen-units=n옵션을 사용하여 동시 작업의 수를 제어할 수 있습니다. - 타입 검사, 빌림 검사, MIR 최적화처럼 HIR 로워링 이후부터 코드 생성까지의 부분은 nightly 버전에서 병렬화되어 있습니다. 현재 이 부분들은 기본적으로 직렬로 실행되며, 사용자가
-Z threads = n옵션을 사용하여 수동으로 병렬화를 활성화해야 합니다. - 어휘 파싱, HIR 로워링, 매크로 확장 같은 다른 부분은 여전히 직렬 모드로 실행됩니다.
코드 생성
단형화(monomorphization) 과정에서 컴파일러는 생성할 모든 코드를 코드 생성 단위라고 하는 더 작은 청크로 나눕니다. 그런 다음 이 청크들은 병렬로 실행되는 독립적인 LLVM 인스턴스들이 생성합니다. 마지막으로 링커가 실행되어 모든 코드 생성 단위를 하나의 바이너리로 결합합니다. 이 과정은 rustc_codegen_ssa::base 모듈에서 이루어집니다.
자료 구조
병렬 컴파일러에서 사용하는 기반 스레드 안전 데이터 구조는 rustc_data_structures::sync 모듈에서 찾을 수 있습니다. 이 자료 구조는 parallel-compiler가 true인지에 따라 다르게 구현됩니다.
| 자료 구조 | 병렬 | 비병렬 |
|---|---|---|
| Lock<T> | (parking_lot::Mutex<T>) | (std::cell::RefCell) |
| RwLock<T> | (parking_lot::RwLock<T>) | (std::cell::RefCell) |
| ReadGuard | parking_lot::RwLockReadGuard | std::cell::Ref |
| MappedReadGuard | parking_lot::MappedRwLockReadGuard | std::cell::Ref |
| WriteGuard | parking_lot::RwLockWriteGuard | std::cell::RefMut |
| MappedWriteGuard | parking_lot::MappedRwLockWriteGuard | std::cell::RefMut |
| LockGuard | parking_lot::MutexGuard | std::cell::RefMut |
-
이러한 스레드 안전 자료 구조는 컴파일 과정 곳곳에서 사용됩니다. 이 때문에 락 경합이 발생할 수 있으며, 스레드 수가 4개를 넘으면 성능이 저하됩니다. 그래서 이러한 자료 구조가 어떻게 사용되는지 점검합니다. 점검 결과에 따라 공유 상태 사용을 줄이도록 리팩터링하거나, 불변 조건의 세부 사항과 원자성, 락 순서를 설명하는 문서를 작성해 계속 유지합니다.
-
한편, 컴파일 중의 다른 불변 조건 가운데 병렬 컴파일에서 성립하지 않을 수 있는 것이 무엇인지는 아직 파악해야 합니다.
WorkerLocal
WorkerLocal은 병렬 컴파일러를 위해 구현된 특수한 자료 구조입니다. 이 자료 구조는 스레드 풀의 각 스레드에 대한 워커 로컬 값을 보관합니다. 워커 로컬 값에는 그 값이 생성된 스레드 풀에서 Deref impl을 통해서만 접근할 수 있습니다. 그렇지 않으면 패닉이 발생합니다.
WorkerLocal은 병렬 환경에서 Arena 할당자를 구현하는 데 사용되며, 이 할당자는 병렬 쿼리에서 매우 중요합니다. 구현은 rustc_data_structures::sync::worker_local 모듈에 있습니다. 그러나 비병렬 컴파일러에서는 (OneThread<T>)로 구현되며, 이때 T에는 Deref::deref를 통해 직접 접근할 수 있습니다.
병렬 반복자
rayon 크레이트가 제공하는 병렬 반복자를 사용하면 병렬성을 쉽게 구현할 수 있습니다. 현재 병렬 컴파일러 구현에서는 작업을 병렬로 실행하기 위해 rayon의 커스텀 포크를 사용합니다.
일부 반복자 함수는 parallel-compiler가 true일 때 루프를 병렬로 실행하도록 구현되어 있습니다.
함수(Send와 Sync는 생략) | 소개 | 소속 모듈 |
|---|---|---|
| par_iter<T: IntoParallelIterator>(t: T) -> T::Iter | 병렬 반복자를 생성합니다 | rustc_data_structure::sync |
| par_for_each_in<T: IntoParallelIterator>(t: T, for_each: impl Fn(T::Item)) | 병렬 반복자를 생성하고 각 요소에 대해 for_each를 실행합니다. | rustc_data_structure::sync |
| Map::par_body_owners(self, f: impl Fn(LocalDefId)) | 크레이트의 모든 HIR 소유자(owner)에 대해 f를 실행합니다. | rustc_middle::hir::map |
| Map::par_for_each_module(self, f: impl Fn(LocalDefId)) | 크레이트의 모든 모듈과 하위 모듈에 대해 f를 실행합니다. | rustc_middle::hir::map |
| ModuleItems::par_items(&self, f: impl Fn(ItemId)) | 모듈의 모든 아이템에 대해 f를 실행합니다. | rustc_middle::hir |
| ModuleItems::par_trait_items(&self, f: impl Fn(TraitItemId)) | 모듈의 모든 트레이트 아이템에 대해 f를 실행합니다. | rustc_middle::hir |
| ModuleItems::par_impl_items(&self, f: impl Fn(ImplItemId)) | 모듈의 모든 impl 아이템에 대해 f를 실행합니다. | rustc_middle::hir |
| ModuleItems::par_foreign_items(&self, f: impl Fn(ForeignItemId)) | 모듈의 모든 외부(foreign) 아이템에 대해 f를 실행합니다. | rustc_middle::hir |
컴파일러에는 이러한 함수를 사용하여 병렬화할 수 있는 루프가 많이 있습니다. 2022년 8월 기준으로, 병렬 이터레이터 함수가 사용된 경우는 다음과 같습니다:
| 호출자 | 시나리오 | 피호출자 |
|---|---|---|
| rustc_metadata::rmeta::encoder::prefetch_mir | 나중에 메타데이터 인코딩에 필요한 쿼리를 미리 가져옵니다. | par_iter |
| rustc_monomorphize::collector::collect_crate_mono_items | 제네릭이 아닌 항목에서 도달할 수 있는 단형화된 항목을 수집합니다 | par_for_each_in |
| rustc_interface::passes::analysis | match 문의 유효성을 검사합니다 | Map::par_body_owners |
| rustc_interface::passes::analysis | MIR 빌림 검사 | Map::par_body_owners |
| rustc_typeck::check::typeck_item_bodies | 타입 검사 | Map::par_body_owners |
| rustc_interface::passes::hir_id_validator::check_crate | hir의 유효성을 검사합니다 | Map::par_for_each_module |
| rustc_interface::passes::analysis | 루프 본문, 속성, naked 함수, 불안정 ABI, const 본문의 유효성을 검사합니다 | Map::par_for_each_module |
| rustc_interface::passes::analysis | MIR의 활성 검사 및 내재적 검사 | Map::par_for_each_module |
| rustc_interface::passes::analysis | 죽은 코드 검사 | Map::par_for_each_module |
| rustc_interface::passes::analysis | 가시성 검사 | Map::par_for_each_module |
| rustc_lint::late::check_crate | 모듈별 린트를 실행합니다 | Map::par_for_each_module |
| rustc_typeck::check_crate | 적격성 검사 | Map::par_for_each_module |
병렬 반복자를 사용할 수 있는 루프가 아직 많이 남아 있습니다.
쿼리 시스템
쿼리 모델에는 큰 노력 없이도 여러 쿼리를 실제로 병렬 평가할 수 있게 해 주는 몇 가지 속성이 있습니다.
- 쿼리 프로바이더가 접근할 수 있는 모든 데이터는 쿼리 컨텍스트를 통하므로, 쿼리 컨텍스트가 접근 동기화를 처리할 수 있습니다.
- 쿼리 결과는 서로 다른 스레드에서 동시에 안전하게 사용할 수 있도록 불변이어야 합니다.
쿼리 foo를 평가할 때 foo의 캐시 테이블이 잠깁니다.
- 이미 결과가 있으면 그 결과를 복제하고 잠금을 해제하면 끝납니다.
- 캐시 항목이 없고 같은 결과를 계산하는 다른 활성 쿼리 호출도 없으면, 해당 키를 “진행 중“으로 표시하고 잠금을 해제한 뒤 평가를 시작합니다.
- 같은 키에 대한 다른 쿼리 호출이 진행 중이면 잠금을 해제하고, 다른 호출이 우리가 기다리는 결과를 계산할 때까지 스레드를 그저 블록합니다. 병렬 컴파일러에서의 순환 오류 탐지는 단일 스레드 모드보다 더 복잡한 로직을 필요로 합니다. 병렬 쿼리의 작업자 스레드들이 상호 의존성 때문에 더 이상 진행하지 못하면, 컴파일러는 추가 스레드(교착 상태 처리기라고 부름)를 사용해 순환 오류를 감지하고 제거하며 보고합니다.
병렬 쿼리 기능에는 아직 구현할 부분이 남아 있으며, 그 대부분은 앞서 설명한 Data Structures 및 Parallel Iterators와 관련되어 있습니다. 이 기능에 대한 열린 추적 이슈를 참조하십시오.
Rustdoc
2022년 11월 기준으로, `rustdoc` 렌더링을 병렬화하려면 아직 완료해야 할 단계가 여러 개 남아 있습니다([병렬 `rustdoc`][parallel-rustdoc]에 관한 공개 논의를 참고하십시오).참고 자료
더 자세히 알아보는 데 활용할 수 있는 자료는 다음과 같습니다.
Rustdoc 내부 구조
이 페이지는 rustdoc의 패스와 모드를 설명합니다. rustdoc의 개요는 “Rustdoc 개요” 장을 참조하십시오.
크레이트에서 clean까지
core.rs에는 두 가지 핵심 항목이 있습니다. 바로 rustdoc::core::DocContext struct와 rustdoc::core::run_global_ctxt 함수입니다. 후자는 rustdoc이 rustc를 호출하여, rustdoc이 작업을 넘겨받을 수 있는 시점까지 크레이트를 컴파일하는 곳입니다. 전자는 크레이트를 순회하며 문서를 수집할 때 사용하는 상태 컨테이너입니다.
크레이트 순회의 주요 과정은 clean/mod.rs에서 이름이 clean_으로 시작하는 여러 함수를 통해 이루어집니다. 각 함수는 hir 또는 ty 자료 구조를 받아 rustdoc가 사용하는 clean 구조체를 출력합니다. 예를 들어, 라이프타임을 변환하는 이 함수는 다음과 같습니다.
fn clean_lifetime<'tcx>(lifetime: &hir::Lifetime, cx: &mut DocContext<'tcx>) -> Lifetime {
if let Some(
rbv::ResolvedArg::EarlyBound(did)
| rbv::ResolvedArg::LateBound(_, _, did)
| rbv::ResolvedArg::Free(_, did),
) = cx.tcx.named_bound_var(lifetime.hir_id)
&& let Some(lt) = cx.args.get(&did).and_then(|arg| arg.as_lt())
{
return lt.clone();
}
Lifetime(lifetime.ident.name)
}
또한 clean/mod.rs는 이후 문서 페이지를 렌더링하는 데 사용되는 “정리된(cleaned)” 추상 구문 트리(AST)의 타입을 정의합니다. 각 타입에는 보통 rustc의 어떤 AST 또는 고수준 중간 표현(HIR) 타입을 받아 적절한 “정리된” 타입으로 변환하는 clean_* 함수가 함께 있습니다. 모듈이나 연관 항목 같은 “큰” 항목은 clean 함수에서 추가 처리를 거칠 수 있지만, 대부분의 경우 이러한 impl은 단순한 변환입니다. 이 모듈의 “진입점“은 clean::utils::krate이며, 이 함수는 run_global_ctxt에서 호출됩니다.
clean::utils::krate의 첫 번째 단계는 visit_ast::RustdocVisitor를 호출하여 모듈 트리를 중간 형태인 visit_ast::Module로 처리하는 것입니다. 이 단계에서 실제로 rustc_middle::hir::Crate를 순회하며, 다음과 같은 이름 해석의 여러 측면을 정규화합니다.
#[doc(inline)]및#[doc(no_inline)]처리- 중복이나 무한한 디렉터리 트리가 생기지 않도록 import glob과 순환 처리
- 비공개 항목의 공개
use내보내기를 인라이닝하거나, 모듈 페이지에 “Reexport” 줄을 표시하는 것 - 기본 항목이 숨겨져 있지만
#[doc(hidden)]이 붙은 항목을 인라이닝하는 것 - 재내보내기로 정의되었는지 여부와 관계없이
#[macro_export]된 매크로를 크레이트 루트에 표시
이 단계 이후 clean::krate는 clean_doc_module을 호출하며, 이 함수가 실제로 HIR 항목을 정리된 AST로 변환합니다. 이 단계는 크레이트 간 인라이닝이 수행되는 단계이기도 하며, 이를 위해서는 rustc_middle 자료 구조를 정리된 AST로 변환해야 합니다.
clean/mod.rs에서 일어나는 또 다른 주요 작업은 문서 주석과 #[doc=""] 속성을 Attributes struct의 별도 필드로 수집하는 것이며, 이 구조체는 직접 작성된 문서가 있는 모든 항목에 존재합니다. 이를 통해 이후 과정에서 이 문서를 더 쉽게 수집할 수 있습니다.
이 과정의 주요 출력은 대상 크레이트에서 공개적으로 문서화할 수 있는 항목을 기술하는 Item의 트리를 가진 clean::types::Crate입니다.
주유소 빼고 다 지나가는 패스(또는: 뜨거운 감자)
다음 주요 단계로 넘어가기 전에, 정리된 AST에 대해 몇 가지 중요한 “패스“가 수행됩니다. 이러한 패스 중 일부는 lint와 보고이지만, 일부는 항목을 변경하거나 새 항목을 생성합니다.
이들은 모두 librustdoc/passes 디렉터리에 패스당 파일 하나씩 구현되어 있습니다. 기본적으로 이 모든 패스가 크레이트에 대해 실행되지만, 비공개/숨겨진 항목을 제거하는 패스는 rustdoc에 --document-private-items를 전달하여 건너뛸 수 있습니다. 이전의 AST 변환들과 달리, 이 패스들은 정리된 크레이트에 대해 실행된다는 점에 유의하십시오.
-
calculate-doc-coverage는--show-coverage플래그에 사용되는 정보를 계산합니다. -
check-doc-test-visibility는doctest가시성과 관련된lint들을 실행합니다. 이 패스는strip-private보다 먼저 실행되므로run-lints와 분리되어 있어야 합니다. -
collect-intra-doc-links는 문서 내부 링크(intra-doc links)를 해석합니다. -
collect-trait-impls는 크레이트의 각 항목에 대한traitimpl들을 수집합니다. 예를 들어,trait를 구현하는struct를 정의하면 이 패스는 해당struct가 그trait를 구현한다는 사실을 기록합니다. -
propagate-doc-cfg는#[doc(cfg(...))]를 하위 항목으로 전파합니다. -
run-lints는passes/lint에 정의된rustdoc의lint중 일부를 실행합니다. 이것이 마지막으로 실행되는 패스입니다.-
bare_urls는 링크로 만들어지지 않은 링크를 감지합니다. 예를 들어 Markdown의Go to https://example.com/.와 같은 경우입니다. 이를 링크로 만들기 위해Go to <https://example.com/>.처럼 링크를 꺾쇠괄호로 감싸도록 제안합니다. 이것이rustdoc::bare_urlslint의 바탕이 되는 코드입니다. -
check_code_block_syntax는 Rust 코드 블록 내부의 구문을 검증합니다(```rust) -
html_tags는 문서 주석에서 유효하지 않은HTML(닫히지 않은<span>등)을 감지합니다.
-
-
strip-hidden과strip-private는 출력에서 모든doc(hidden)항목과 비공개 항목을 제거합니다.strip-private는strip-priv-imports를 포함합니다. 기본적으로 그 목표는 공개 문서와 관련이 없는 항목을 제거하는 것입니다.--document-hidden-items가 전달되면 이 패스는 건너뜁니다. -
strip-priv-imports는 크레이트에서 모든 비공개 임포트 문(use,extern crate)을 제거합니다. 이는rustdoc가 공개 import를 처리할 때 항목의 문서를 모듈에 인라이닝하거나 해당 import를 포함하는 “Reexports” 섹션을 만들기 때문에 필요합니다. 이 패스는 이러한 임포트가 모두 실제로 문서와 관련이 있도록 보장합니다. 엄밀히 말하면 이 패스는--document-private-items가 전달될 때만 실행되지만,strip-private도 같은 작업을 수행합니다. -
strip-private는 크레이트에서 외부에서 볼 수 없는 모든 비공개 항목을 제거합니다.--document-private-items가 전달되면 이 패스는 건너뜁니다.
librustdoc/passes에는 stripper 모듈도 있지만, 이는 strip-* 패스들을 위한 유틸리티 함수 모음일 뿐 그 자체로 패스는 아닙니다.
clean에서 HTML까지
여기서부터 rustdoc의 “두 번째 단계“가 시작됩니다. 이 단계는 주로 librustdoc/formats 및 librustdoc/html 폴더에 있으며, 모든 것은 formats::renderer::run_format에서 시작합니다. 이 코드는 impl FormatRenderer인 타입을 설정하는 역할을 하며, HTML의 경우 그 타입은 Context입니다.
이 구조체에는 문서 렌더링을 진행하기 위해 run_format이 호출하는 메서드들이 들어 있으며, 다음과 같습니다:
init는static.files뿐만 아니라 검색 인덱스와src/도 생성합니다.item은 항목HTML파일 자체를 생성합니다.after_krate는all.html과 같은 그 밖의 전역 리소스를 생성합니다.
item에서는 html/layout.rs에서 시작하여 Askama 템플릿과 수동 write!() 호출을 혼용하는 방식으로 “페이지 렌더링“이 이루어집니다. 아직 템플릿으로 전환되지 않은 부분은 일련의 std::fmt::Display 구현과 &mut std::fmt::Formatter를 주고받는 함수 안에서 처리됩니다.
항목과 문서로부터 실제로 HTML을 생성하는 부분은 html/render/print_item.rs에 정의된 print_item에서 시작하며, 이 함수는 렌더링되는 Item의 종류에 따라 여러 item_* 함수 중 하나로 분기합니다.
찾고 있는 렌더링 코드의 종류에 따라, “struct 페이지에 어떤 섹션을 출력해야 하는가“와 같은 주요 항목은 html/render/mod.rs에서, “다른 항목의 일부로 where 절을 어떻게 출력해야 하는가“와 같은 더 작은 구성 요소는 html/format.rs에서 찾을 수 있을 것입니다.
rustdoc은 직접 작성된 문서를 함께 출력해야 하는 항목을 만날 때마다 Markdown 파서와 연동하는 html/markdown.rs를 호출합니다. 이는 Markdown 문자열을 감싸고 fmt::Display를 구현하여 HTML 텍스트를 출력하는 일련의 타입으로 제공됩니다. 이 모듈은 Markdown 파서를 실행하기 전에 각주와 표 같은 특정 기능을 활성화하고 (html/highlight.rs를 통해) Rust 코드 블록에 구문 강조를 추가하는 데 특히 주의를 기울입니다. 또한 find_testable_codes가 호출하는 find_codes 함수도 있는데, 이 함수는 테스트 실행 코드가 크레이트 안의 모든 doctest를 찾을 수 있도록 Rust 코드 블록만을 골라 탐색합니다.
처음부터 끝까지 (또는: “최초의 Cell들로부터 우리에게까지 끊어지지 않은 실이 이어져 있습니다”)
rustdoc은 HTML을 생성하는 도중에도 컴파일러에 타입 정보를 직접 요청할 수 있다는 점에 유의해야 합니다. 예전에는 그렇지 않았기 때문에 rustdoc 아키텍처의 많은 부분이 그렇게 하지 않는 것을 전제로 설계되었지만, 이제는 formats::renderer::run_format에 TyCtxt가 전달됩니다. 이 함수는 HTML과 ( 2026년 5월 기준 불안정한) JSON 형식 모두의 생성을 실행하는 데 사용됩니다.
이 변경 덕분에 TyCtxt 쿼리로 쉽게 도출할 수 있는 데이터를 “clean” AST에서 제거하는 다른 변경들이 가능해졌으며, “clean“에서 필드를 제거하는 PR은 대체로 받아들여지지만(clean은 사실상 폐기 예정 상태입니다), 이는 rustdoc이 따라야 하는 다른 두 가지 제약 때문에 복잡해집니다.
- 실제로 타입 검사를 통과하지 못하는 크레이트에 대해서도 문서를 생성할 수 있습니다. 이는
libstd가 지원되는 모든 운영 체제를 아우르는 단일 문서 패키지를 갖는 경우처럼, 상호 배타적인 플랫폼 구성을 모두 다루는 문서를 생성하는 데 사용됩니다. 즉,rustdoc은HIR로부터 문서를 생성할 수 있어야 합니다. - 문서는 크레이트 경계를 넘어 인라인될 수 있습니다. 크레이트 메타데이터에는
HIR가 포함되지 않으므로,rustc_middle데이터로부터 인라인된 문서를 생성할 수 있어야 합니다.
“clean” AST는 두 입력 형식 모두에 대한 공통 출력 형식 역할을 합니다. 또한 clean에는 자동 트레이트에 대한 합성 impl이나 collect-trait-impls 패스가 생성하는 블랭킷 impl처럼 HIR에 직접 대응하지 않는 데이터도 일부 있습니다.
일부 추가 데이터는 html::render::context::{Context, SharedContext}에 저장됩니다. 이 두 타입은 향후 다중 스레드 문서 생성을 대비하여 rustdoc의 데이터를 분리하는 수단이자, 단순히 코드를 정리된 상태로 유지하는 수단이기도 합니다.
Context는 현재 페이지의 경로, (중복된id=""를 피하기 위해) 이미 사용된HTMLID 목록,SharedContext에 대한 포인터 등 현재 페이지를 생성하는 데 사용되는 데이터를 저장합니다.SharedContext는tcx포인터와 모든 타입의 목록처럼 페이지마다 달라지지 않는 데이터를 저장합니다.
그 밖의 숨은 기능
지금까지는 Rust 크레이트로부터 HTML 문서를 생성하는 과정을 설명했지만, rustdoc에는 이 밖에도 몇 가지 주요 실행 모드가 있습니다. 독립된 Markdown 파일에 대해 실행할 수도 있고, Rust 코드나 독립된 Markdown 파일의 doctest를 실행할 수도 있습니다. 전자의 경우 곧바로 html/markdown.rs로 넘어가며, 출력 HTML에 목차를 삽입하는 모드를 선택적으로 포함할 수 있습니다.
후자의 경우 rustdoc은 test.rs에서 관련 문서를 얻기 위해 비슷한 부분 컴파일을 실행하지만, 전체 정리 및 렌더링 과정을 거치는 대신 훨씬 단순한 크레이트 순회를 실행하여 직접 작성된 문서만 가져옵니다. 앞서 언급한 html/markdown.rs의 “find_testable_code“와 함께, 실행할 테스트 모음을 빌드한 후 테스트 러너에 넘깁니다. test.rs에서 주목할 만한 위치 중 하나는 make_test 함수로, 직접 작성된 doctest가 실행 가능한 형태로 변환되는 곳입니다.
make_test에 관한 추가 자료는 여기에서 찾을 수 있습니다.
로컬에서 테스트하기
생성된 HTML 문서의 일부 기능은 페이지 간에 로컬 스토리지를 사용해야 할 수 있는데, 이는 HTTP 서버 없이는 제대로 작동하지 않습니다. 이러한 기능을 로컬에서 테스트하려면 다음과 같이 로컬 HTTP 서버를 실행할 수 있습니다:
$ ./x doc library
# The documentation has been generated into `build/[YOUR ARCH]/doc`.
$ python3 -m http.server -d build/[YOUR ARCH]/doc
이제 문서가 인터넷에 호스팅되어 있을 때와 똑같이 문서를 탐색할 수 있습니다. 예를 들어 std의 url은 rust/std/입니다.
함께 보기
Rustdoc 검색
Rustdoc 검색은 search_index.rs와 search.js라는 두 프로그램으로 구성됩니다. 전자는 문서 번들에 포함된 크레이트의 모든 항목과 함수 시그니처 목록을 담은 지저분한 JSON 파일을 생성하고, 후자는 이 파일을 읽어 메모리 내 구조체로 변환한 뒤 이를 선형으로 훑어 검색합니다.
검색 인덱스 형식
search.js는 이것을 로드한 후 더 일반적인 객체 트리로 변환하므로 이것을 Raw라고 부릅니다. 공간을 절약하기 위해 줄바꿈이나 공백 없이 작성되기도 합니다.
[
[ "crate_name", {
// name
"n": ["function_name", "Data"],
// type
"t": "HF",
// parent module
"q": [[0, "crate_name"]],
// parent type
"i": [2, 0],
// type dictionary
"p": [[1, "i32"], [1, "str"], [5, "Data", 0]],
// function signature
"f": "{{gb}{d}}`", // [[3, 1], [2]]
// impl disambiguator
"b": [],
// deprecated flag
"c": "OjAAAAAAAAA=", // empty bitmap
// empty description flag
"e": "OjAAAAAAAAA=", // empty bitmap
// aliases
"a": [["get_name", 0]],
// description shards
"D": "g", // 3
// inlined re-exports
"r": [],
}]
]
src/librustdoc/html/static/js/rustdoc.d.ts는 TypeScript type으로 실제 스키마를 정의합니다.
| 키 | 이름 | 설명 |
|---|---|---|
n | 이름 목록 | 항목 이름 |
t | 항목 타입 | 한 글자로 된 항목 타입 코드 |
q | 부모 모듈 | Map<index, path> |
i | 부모 타입 | 인덱스 목록 |
f | 함수 시그니처 | 인코딩됨 |
b | impl 구분자 | Map<index, string> |
c | 사용 중단 플래그 | 로어링 비트맵 |
e | 설명이 비어 있습니다 | 로어링 비트맵 |
p | 타입 사전 | [[item type, path]] |
a | 별칭 | Map<string, index> |
D | 설명 샤드 | 인코딩됨 |
위 인덱스는 crate_name이라는 크레이트를 정의합니다. 이 크레이트에는 function_name이라는 자유 함수와 Data라는 구조체가 있으며, 함수의 타입 시그니처는 Data, i32 -> str이고, function_name을 동일하게 가리키는 별칭 get_name도 있습니다.
검색 인덱스는 rustdoc 컴파일러와 search.js 프런트엔드의 요구를 모두 충족해야 하며, 크기가 작고 디코딩도 빨라야 합니다. 이를 위해 많은 부분에서 절충합니다:
rustdoc컴파일러는 한 번에 하나의 크레이트만 처리하므로, 각 크레이트는 사실상 별도의 검색 인덱스를 가집니다. 각 크레이트를 한 줄에 하나씩 두고 첫 번째 따옴표 문자열을 확인하는 방식으로 이들을 병합합니다.- 검색 인덱스의 이름은 원래의 대소문자와 밑줄을 그대로 유지합니다. 검색 인덱스를 불러오면
search.js는 표시용으로 원래 이름을 저장하는 한편, 검색용으로는 이름을 소문자로 바꾸고 밑줄을 제거합니다. 이렇게 처리된 이름은normalized라고 불립니다. f배열은 타입을p배열에 대한 오프셋으로 저장합니다. 이 타입들은 실제로 다른 크레이트에서 온 것일 수 있으므로, 같은 인덱스에 있는 여러 크레이트가 같은 타입을 언급하는 경우search.js는 중복을 제거하기 위해 숫자를 이름으로 바꾼 뒤 다시 숫자로 바꿔야 합니다.- 이 파일은 JSON 파일이지만, 사람이 읽기 쉽도록 설계되지는 않았습니다. 브라우저에는 이미 최적화된 JSON 디코더가 포함되어 있으므로
search.js코드를 줄일 수 있고 작은 크레이트에서 성능도 더 좋습니다. 다만 일반적인 JSON 형식처럼 객체를 사용하는 대신, DEFLATE가 사용하는 슬라이딩 윈도우가 중복을 찾을 수 있도록 같은 타입의 데이터를 서로 인접하게 배치하려고 합니다.search.js가 자체적으로 압축을 수행하는 부분은 디스크 크기나 네트워크 전송량만이 아니라, 파일을 최종적으로 불러왔을 때의 메모리도 절약하도록 설계되었습니다.
병렬 배열과 인덱스 맵
추상적으로 보면 Rustdoc 검색 데이터는 열 우선(column-major) 형태로 저장된 표입니다. 인덱스의 데이터 대부분은 병렬 배열(“열”)의 집합을 나타내며, 같은 위치에 있는 값들은 같은 데이터를 가리킵니다.
예를 들어, 위 검색 인덱스는 다음과 같은 표로 바꿀 수 있습니다:
| n | t | d | q | i | f | b | c | |
|---|---|---|---|---|---|---|---|---|
| 0 | crate_name | D | 문서 | NULL | 0 | NULL | NULL | 0 |
| 1 | function_name | H | 이 함수는 Data를 사용하여 정수의 이름을 가져옵니다 | crate_name | 2 | {{gb}{d}} | NULL | 0 |
| 2 | Data | F | 데이터 구조체 | crate_name | 0 | ` | NULL | 0 |
크레이트 행은 대부분의 열에서 암시됩니다. 타입이 이미 알려져 있고(크레이트이므로), 부모를 가질 수 없으며(크레이트는 모듈 트리의 루트이므로), 이름은 맵 키로 지정되고, impl 구분자 같은 함수 전용 데이터도 적용될 수 없기 때문입니다. 하지만 크레이트도 설명을 가질 수 있고 지원 중단(deprecated)될 수도 있습니다. 따라서 크레이트의 기본 키는 0입니다.
위 코드는 지원 중단된 인덱스를 담는 c나 인덱스를 문자열에 매핑하는 b를 사용하지 않습니다. crate_name::function_name이 둘 다 사용한다면 다음과 같은 모습이 될 것입니다.
"b": [[0, "impl-Foo-for-Bar"]],
"c": "OjAAAAEAAAAAAAIAEAAAABUAbgZYCQ==",
이는 인덱스 1에 구분자를 붙이고 해당 항목을 지원 중단으로 표시합니다.
이 레이아웃의 장점은 이러한 API에 DEFLATE는 활용할 수 있지만 rustdoc은 가정할 수 없는 암묵적인 구조가 흔히 있다는 점입니다. 예를 들어 이름은 보통 CamelCase나 snake_case이지만 설명은 그렇지 않습니다. 또한 불리언 플래그 같은 것에 희소 데이터를 사용하기도 더 쉬워집니다.
q는 처음으로 적용 가능한 ID를 부모 모듈 경로에 매핑하는 Map입니다. 이상한 기법이지만 의사 코드로 보면 더 이해하기 쉽습니다:
#![allow(unused)]
fn main() {
let mut parent_module = "";
for (i, entry) in search_index.iter().enumerate() {
if q.contains(i) {
parent_module = q.get(i);
}
// ... do other stuff with `entry` ...
}
}
이 방식이 유효한 이유는 모든 항목에 (크레이트 자체일지라도) 부모 모듈이 있기 때문이며, rustdoc 생성기가 직렬화하기 전에 경로를 기준으로 정렬하므로 구성하기도 쉽습니다. 이렇게 하면 rustdoc은 검색 인덱스를 더 작게 만들 수 있을 뿐만 아니라, 부모 경로를 나타내는 동일한 문자열을 메모리 내의 여러 항목에서 재사용할 수 있습니다.
희소 열 표현하기
VLQ Hex
제가 아는 한 이 형식은 rustdoc 외에는 어디에서도 사용되지 않습니다. 이 형식은 다음 문법을 따릅니다:
VLQHex = { VHItem | VHBackref }
VHItem = VHNumber | ( '{', {VHItem}, '}' )
VHNumber = { '@' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' }, ( '`' | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k ' | 'l' | 'm' | 'n' | 'o' )
VHBackref = ( '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | ':' | ';' | '<' | '=' | '>' | '?' )
VHNumber는 가변 길이의 자기 종료형 16진수 숫자입니다(마지막 16진 자릿수만 소문자이고 나머지는 모두 대문자이기 때문에 끝을 알 수 있습니다). 여기서 “가변“은 길이를 나타내는 변수 값이 정해져 있지 않다는 뜻입니다. 부호 비트는 지그재그 인코딩을 사용하여 표현합니다.
이 알파벳을 선택한 이유는 ASCII 인코딩의 마지막 네 비트만 남기고 마스킹하면 문자를 헥싯으로 바꿀 수 있기 때문입니다.
rustdoc에서 수행하는 모든 “압축“과 마찬가지로, 이 인코딩의 주요 기능은 런타임에 메모리 안에서도 압축된 형식을 유지할 수 있다는 점입니다. 이것이 HBackref가 최상위 수준에서만 사용되는 이유이자, 모든 것에 그냥 Flate를 사용하지 않는 이유입니다: search.js의 디코더는 백레퍼런스를 만날 때마다 디코딩된 객체 전체를 재사용하므로 디코딩 작업과 메모리를 절약합니다.
Roaring 비트맵
지원 중단 여부나 빈 설명 같은 플래그 형태의 데이터는 런(run)을 포함한 표준 Roaring Bitmap 직렬화 형식을 사용하여 저장됩니다. 그런 다음 데이터를 기록할 때 base64로 인코딩합니다.
간단히 개요를 설명하면, roaring 비트맵은 이 논문에 설명된 청크 단위의 비트 배열입니다. 청크는 정수 목록, 비트필드, 런 목록 중 하나일 수 있습니다. 어느 경우든 검색 엔진은 이를 base64로 디코딩하고 청크 인덱스를 직접 읽어야 하지만, 페이로드 데이터는 그대로 유지됩니다.
현재 rustdoc의 모든 roaring 비트맵은 항목 인덱스마다 플래그를 하나씩 저장합니다. 크레이트는 항목 0이며, 나머지는 모두 1부터 시작합니다.
설명이 저장되는 방식
가장 많은 양을 차지하는 데이터이자, Rustdoc Search가 다루는 것 중 실제로 검색에는 사용되지 않는 주된 데이터는 설명입니다. SERP 표에서 설명은 가장 오른쪽 열에 표시됩니다.
항목 타입 항목 경로 설명 (이 부분) 함수 my_crate::my_function 이 함수는 Data로 정수의 이름을 가져옵니다
누군가 rustdoc에서 처음으로 검색을 실행하면, 브라우저는 세 단계로 이루어진 “샌드위치 작업 부하“를 처리합니다:
- search-index.js와 search.js 파일을 다운로드합니다(네트워크 병목).
- 실제 검색을 수행합니다(CPU 및 메모리 대역폭 병목).
- 설명 데이터를 다운로드합니다(또 다른 네트워크 병목).
여기서 다운로드하는 데이터의 양을 줄이면 거의 항상 지연 시간이 늘어납니다. 무엇을 다운로드할지 결정하는 일이 다른 작업 뒤로 밀리거나, 다른 것을 먼저 다운로드해야만 무언가를 다운로드할 수 있는 데이터 의존성이 추가되기 때문입니다. 이 경우에는 검색이 끝나기 전까지 설명 다운로드를 시작할 수 없습니다. 검색이 끝나야 어떤 설명을 다운로드할지 결정할 수 있기 때문입니다(결과를 정렬한 다음 200개로 잘라야 합니다).
이를 위해 검색 인덱스에는 Roaring Bitmap과 VLQ Hex를 모두 기반으로 하는 두 개의 열이 저장됩니다.
e는 비어 있는(empty) 설명의 인덱스입니다. 이것은 각 항목에 대한 roaring bitmap입니다(크레이트 자체가 항목 0이고, 나머지는 1부터 시작합니다).D는 샤드 목록으로, VLQ hex로 된 평탄한 정수 목록 형태로 저장됩니다. 각 정수는 해당 샤드에 들어 있는 설명의 개수를 나타냅니다. 디코더는 인덱스를 순회하면서 설명이 비어 있는지 확인합니다. 비어 있지 않다면 그 설명은 “현재” 샤드에 속합니다. 모든 항목을 다 소진하면 다음 샤드로 넘어갑니다.
각 샤드 안에는 줄바꿈으로 구분된 설명 목록이 JSONP 스타일의 함수 호출로 감싸여 들어 있습니다.
i, f, p
i와 f는 모두 부모 항목의 배열인 p를 가리키는 인덱스입니다.
i는 단순히 1부터 시작하는 숫자입니다(0부터 시작하지 않는 이유는 0이 부모 항목이 없는 항목에 사용되기 때문입니다). 이것은 q와 다릅니다. q는 모든 항목이 가지고 있는 부모 모듈 또는 크레이트를 나타내는 반면, i/q는 메서드와 같은 타입 및 트레이트 연관 항목에 사용되기 때문입니다.
함수 시그니처인 f는 VLQ hex 트리를 사용합니다. 각 숫자는 p를 가리키는 1부터 시작하는 참조이거나, 제네릭을 나타내는 음수이거나, null을 나타내는 0입니다.
(내부 객체 표현도 디코딩 이후에도 제네릭을 나타내기 위해 음수를 사용합니다.)
예를 들어 {{gb}{d}}는 json [[3, 1], [2]]와 동일합니다. 지그재그 인코딩 때문에 `는 +0, a는 -0(사용되지 않음), b는 +1, c는 -1입니다.
이름으로 검색하기
이름으로 검색할 때는 검색 인덱스를 순회하면서 각 항목에 다음 함수들을 실행합니다.
editDistance는 일치 여부를 판단하는 데 항상 사용됩니다(따옴표를 지정한 경우는 예외이며, 이때는 단순 동등 비교를 대신 사용합니다). 이 함수는 쿼리 이름을 항목 이름으로 바꾸는 데 필요한 교환, 삽입, 삭제 횟수를 계산합니다. 예를 들어,foo는 자기 자신과의 거리가 0이지만,ofo(교환 1회) 및foob(삽입 1회)와의 거리는 1입니다. 이 거리를 휴리스틱 임곗값과 비교하고, 임곗값 이내이면 순위 결정을 위해 거리를 저장합니다.String.prototype.indexOf는 일치 여부를 판단하는 데 항상 사용됩니다. 이 함수가 -1 이외의 값을 반환하면editDistance가 임곗값을 초과하더라도 결과가 추가되며, 순위 결정을 위해 인덱스가 저장됩니다.checkPath는 쿼리에 부모 경로가 지정된 경우에만 사용됩니다. 예를 들어,vec에는 부모 경로가 없지만vec::vec에는 있습니다. checkPath 안에서는 editDistance와 indexOf가 사용되며, 경로 쿼리에도 자체 휴리스틱 임곗값이 있습니다. 임곗값 이내가 아니면 앞의 두 검사를 통과했더라도 해당 항목은 거부됩니다. 임곗값 이내이면 순위 결정을 위해 경로 거리가 저장됩니다.checkType은struct:vec의 구조체(struct)처럼 타입 필터가 있는 경우에만 사용됩니다. 이 검사에 실패하면 해당 항목은 거부됩니다.
네 가지 기준을 모두 통과하면(엄밀히 말해 쿼리의 일부는 아닌 크레이트 필터도 포함하여) 결과가 sortResults에 의해 정렬됩니다.
타입으로 검색하기
타입으로 검색하는 과정은 두 단계로 나눌 수 있으며, 두 번째 단계는 다시 두 개의 하위 단계로 나뉩니다.
- 쿼리에 있는 이름을 숫자로 변환합니다.
- 검색 인덱스의 각 항목을 루프로 순회합니다:
- 블룸 필터를 사용하여 빠르게 거부합니다.
- 재귀적 타입 통합(unification) 알고리즘을 사용하여 느리게 거부합니다.
이름을 숫자로 변환하는 단계에서 쿼리에 이름이 하나뿐이면 정확한 일치에 실패했을 때 editDistance 함수로 근사 일치를 찾지만, 쿼리에 항목이 여러 개 있으면 일치하지 않는 항목은 대신 제네릭으로 취급됩니다. 즉, hahsmap은 단독으로는 hashmap과 일치하지만, hahsmap, u32는 T, u32와 일치하는 것과 같은 대상과 일치하게 됩니다(다만 rustdoc은 이 특정 문제를 감지하고 경고합니다).
그런 다음 실제로 각 항목을 순회할 때, 블룸 필터는 쿼리에 언급된 타입을 모두 갖고 있지 않은 항목을 대체로 거부합니다. 예를 들어, 블룸 쿼리는 i32 -> u32 쿼리가 i32, u32 -> bool 타입의 함수와 일치하도록 허용하지만, 나중에 통합 단계에서 이를 거부합니다.
통합 필터는 다음 사항을 보장합니다.
- 백(bag) 의미론을 준수합니다. 쿼리가
i32, i32이면 함수는 i32를 하나가 아니라 두 개 언급해야 합니다. - 중첩 의미론을 준수합니다. 쿼리가
vec<option>이면vec<option<i32>>는 일치하지만option<vec<i32>>는 일치하지 않습니다. - 반환 타입과 매개변수의 구분을 준수합니다.
i32 -> u32와u32 -> i32는 완전히 다릅니다.
블룸 필터는 이 중 어느 것도 검사하지 않으며, 게다가 거짓 양성이 발생할 수도 있습니다. 하지만 빠르고 메모리를 매우 적게 사용하므로 블룸 필터는 도움이 됩니다.
재내보내기
재내보내기 인라이닝을 사용하면 같은 항목을 여러 이름으로 찾을 수 있습니다. 검색은 같은 항목에 여러 개의 엔트리를 부여하고, 정규 경로가 주어진 경로와 다른 항목에 대해서는 정규 경로를 추적하는 방식으로 이를 지원합니다.
예를 들어, 다음 샘플 인덱스에는 두 경로에서 내보내진 구조체 하나가 있습니다.
[
[ "crate_name", {
"doc": "Documentation",
"n": ["Data", "Data"],
"t": "FF",
"d": ["The data struct", "The data struct"],
"q": [[0, "crate_name"], [1, "crate_name::submodule"]],
"i": [0, 0],
"p": [],
"f": "``",
"b": [],
"c": [],
"a": [],
"r": [[0, 1]],
}]
]
이 예제에서 중요한 부분은 r 배열로, 이 배열은 q 배열의 경로 엔트리 1이 항목 0의 정규 경로임을 나타냅니다. 즉, crate_name::Data의 정규 경로는 crate_name::submodule::Data입니다.
중복 데이터가 있으므로 이상한 설계처럼 들릴 수도 있습니다. 이렇게 하는 이유는 인라이닝이 크레이트 간에 일어날 수 있기 때문입니다. 크레이트는 따로 컴파일되며, 모든 크레이트가 문서에 포함되어 있지 않을 수도 있습니다.
[
[ "crate_name", ... ],
[ "crate_name_2", { "q": [[0, "crate_name::submodule"], [5, "core::option"]], ... }]
]
위 예제에서 정규 경로 하나는 실제로 의존성에서 오고, 다른 하나는 인라인된 표준 라이브러리 항목에서 옵니다. 정규 경로가 인덱스에 아예 없는 것입니다! 정규 경로가 비공개일 수도 있습니다. 어느 경우든 정규 경로는 사용자에게 절대 표시되지 않으며, 중복 제거에만 사용됩니다.
연관 타입은 메서드와 마찬가지로 이를 다른 방식으로 저장합니다. 이러한 타입은 p의 엔트리(해당 타입의 “부모”)와 연결되며, 각 타입에는 선택적인 세 번째 튜플 요소가 있습니다.
"p": [[5, "Data", 0, 1]]
즉, 다음과 같습니다.
- 5: 구조체입니다
- “Data”: 이름입니다
- 0: 표시 경로인 “crate_name“입니다
- 1: 정규 경로인 “crate_name::submodule“입니다
두 경우 모두 정규 경로는 전혀 공개되지 않은 것일 수도 있고, 문서에 포함되지 않은 다른 크레이트에서 온 것일 수도 있으므로, 사용자에게는 절대 표시되지 않지만 중복 제거에는 사용됩니다.
검색 엔진 테스트하기
생성된 UI는 rustdoc-gui 테스트로 테스트하지만, 검색 엔진을 테스트하는 주된 방법은 rustdoc-js 테스트와 rustdoc-js-std 테스트입니다. 이 테스트들은 NodeJS에서 실행됩니다.
rustdoc-js 테스트에는 이름이 같은 .rs 파일과 .js 파일이 있습니다. .rs 파일은 검색을 실행할 가상의 라이브러리 크레이트를 지정합니다(찾아야 하는 항목은 반드시 pub으로 표시하십시오). .js 파일은 실제 검색을 지정합니다. rustdoc-js-std 테스트도 같지만, 표준 라이브러리를 사용하므로 .rs 파일이 필요하지 않습니다.
.js 파일은 모듈과 비슷합니다(단, exports는 로더가 대신 처리합니다). 이 파일은 다음 변수를 사용합니다.
| 이름 | 타입 | 설명 |
|---|---|---|
FILTER_CRATE | string | 지정한 크레이트의 결과만 포함합니다. GUI에서는 “크레이트의 결과” 드롭다운 메뉴에 해당합니다. |
EXPECTED | [ResultsTable]|ResultsTable | 실행할 테스트 목록으로, 가상의 사용자가 검색 상자에 입력하는 내용과 탭에서 보게 되는 내용을 지정합니다 |
PARSED | [ParsedQuery]|ParsedQuery | 실제 검색은 실행하지 않고 실행할 파서 테스트 목록입니다 |
FILTER_CRATE는 생략할 수 있지만(“all crates“를 검색하는 것과 동일합니다), EXPECTED 또는 PARSED는 반드시 지정해야 합니다.
기본적으로 테스트 케이스에 지정된 결과 중 하나라도 검색을 실행한 후 찾을 수 없거나, 검색을 실행한 후 찾은 결과가 테스트에 적힌 것과 같은 순서로 나타나지 않으면 테스트가 실패합니다. 그러나 실제 검색 결과에는 테스트에 없는 결과가 포함될 수 있습니다. 이 동작을 재정의하려면 다음 매직 주석 중 하나를 지정하십시오. 매직 주석은 들여쓰기 없이 별도의 줄에 작성하십시오.
// exact-check: 테스트 케이스에 포함되지 않은 검색 결과가 나타나면 실패합니다.// ignore-order: 검색 결과가 어떤 순서로 나타나도 허용합니다.// should-fail: 부정 테스트를 작성할 때 사용합니다.
표준 라이브러리 테스트에는 일반적으로 // exact-check를 지정하지 않아야 합니다. libs 팀이 관련 없는 테스트를 실패시키지 않고 새 항목을 추가할 수 있어야 하기 때문입니다. 반면 독립 실행형 테스트에서는 이 주석을 더 자주 사용합니다.
ResultsTable 및 ParsedQuery 타입은 rustdoc.d.ts에 명시되어 있습니다.
예를 들어, constructor라는 이름의 함수를 찾을 수 없는 버그를 수정해야 한다고 가정해 봅시다. 이를 위해 다음 두 파일을 작성하십시오.
#![allow(unused)]
fn main() {
// tests/rustdoc-js/constructor_search.rs
// The test case needs to find this result.
pub fn constructor(_input: &str) -> i32 { 1 }
}
// tests/rustdoc-js/constructor_search.js
// exact-check
// Since this test runs against its own crate,
// new items should not appear in the search results.
const EXPECTED = [
// This first test targets name-based search.
{
query: "constructor",
others: [
{ path: "constructor_search", name: "constructor" },
],
in_args: [],
returned: [],
},
// This test targets the second tab.
{
query: "str",
others: [],
in_args: [
{ path: "constructor_search", name: "constructor" },
],
returned: [],
},
// This test targets the third tab.
{
query: "i32",
others: [],
in_args: [],
returned: [
{ path: "constructor_search", name: "constructor" },
],
},
// This test targets advanced type-driven search.
{
query: "str -> i32",
others: [
{ path: "constructor_search", name: "constructor" },
],
in_args: [],
returned: [],
},
]
//@ revisions 지시문을 사용하면 JS 파일에서 REVISION이라는 변수에 접근할 수 있습니다.
const EXPECTED = [
// This first test targets name-based search.
{
query: "constructor",
others: REVISION === "has_constructor" ?
[
{ path: "constructor_search", name: "constructor" },
] :
[],
in_args: [],
returned: [],
},
];
rustdoc-html 테스트 스위트
이 페이지에서는 rustdoc의 HTML 출력을 테스트하는 데 쓰는 rustdoc-html 테스트 스위트를 설명합니다. 다른 rustdoc 전용 테스트 스위트는 Rustdoc 테스트 스위트를 참조하십시오.
이 테스트 스위트의 각 테스트 파일은 단순한 Rust 소스 파일 file.rs이며, 일반 Rust 코드 주석 안에 이른바 지시문이 곳곳에 들어 있습니다. 지시문에는 Compiletest 지시문과 HtmlDocCk 지시문의 두 종류가 있습니다.
전자에 관해 더 알아보려면 Compiletest 지시문을 읽어 보십시오. 후자에 관해서는 이어지는 내용을 읽어 주십시오.
내부적으로 compiletest는 보조 검사 스크립트인 htmldocck.py를 호출합니다.
HtmlDocCk 지시문
HtmlDocCk 지시문은 생성된 HTML에 제약 조건을 거는 단언문입니다. //@ 주석 형태를 취한다는 점에서 compiletest에 전달하는 지시문과 비슷해 보이지만, 결국 둘은 완전히 별개이며 서로 다른 프로그램이 처리합니다.
HTML 문서 트리의 일부를 쿼리할 때는 XPath를 사용합니다.
입문 예제:
//@ has file/type.Alias.html
//@ has - '//*[@class="rust item-decl"]//code' 'type Alias = Option<i32>;'
pub type Alias = Option<i32>;
여기서는 크레이트 file에 대해 생성된 문서에 공개 타입 별칭 Alias의 페이지가 있고, 그 페이지 맨 위에 있는 코드 블록에 해당 항목이 예상대로 렌더링되어 있는지 검사합니다. //*[@class="rust item-decl"]//code는 XPath 표현식입니다.
관례상 이러한 지시문은 테스트하려는 대상 바로 위에 둡니다. 그러나 HtmlDocCk는 지시문만 찾기 때문에 엄밀히 말해 반드시 그렇게 둘 필요는 없습니다.
모든 지시문은 PATH 인자를 받습니다. 같은 내용을 반복하지 않으려면 이 인자에 -를 전달하여 이전 PATH 인자를 재사용할 수 있습니다. 경로에 크레이트 이름이 들어가므로, 관례적으로 크레이트 루트에 #![crate_name = "foo"] 속성을 추가하여 최종 경로를 짧게 만듭니다.
COUNT와 PATH의 특수한 - 형태를 제외한 모든 인자는 셸 스타일의 따옴표(작은따옴표 또는 큰따옴표)로 감싼 문자열 형태를 취합니다.
모든 지시문(files 제외)은 이름 앞에 !를 붙여 부정할 수 있습니다. 부정 지시문을 추가하기 전에 주의 사항을 읽어 보십시오.
셸 명령과 마찬가지로, 지시문의 마지막 문자가 \이면 지시문을 여러 줄에 걸쳐 쓸 수 있습니다. 이 경우 다음 줄은 @ 없이 //로 시작해야 합니다.
compiletest 지시문과 마찬가지로 지시문 이름과 인자를 구분할 때 공백 외에 콜론 :을 쓸 수도 있지만, HtmlDocCk 지시문에서는 공백을 권장합니다.
CHANNEL이 현재 릴리스 채널(예: stable 또는 nightly)을 가리키는 URL https://doc.rust-lang.org/CHANNEL을 참조하려면 XPath, PATTERN 인자, 스냅숏 파일에서 특수 문자열 {{channel}}을 사용하십시오.
사용할 수 있는 모든 지시문은 다음과 같습니다:
has
사용법 1:
//@ has PATH
PATH로 지정한 파일이 존재하는지 검사합니다.
사용법 2:
//@ has PATH XPATH PATTERN
PATH로 지정한 공백 정규화된1 파일에서 XPATH로 선택한 각 요소/속성/텍스트의 텍스트가 (마찬가지로 공백 정규화된) 문자열 PATTERN과 일치하는지 검사합니다.
팁: 공백 정규화를 피하고 싶거나 정규식으로 매칭하고 싶다면 대신 matches를 사용하십시오.
hasraw
사용법:
//@ hasraw PATH PATTERN
PATH로 지정한 공백 정규화된1 파일의 내용이 (마찬가지로 공백 정규화된) 문자열 PATTERN과 일치하는지 검사합니다.
팁: 공백 정규화를 피하고 싶거나 정규식으로 매칭하고 싶다면 대신 matchesraw를 사용하십시오.
matches
사용법:
//@ matches PATH XPATH PATTERN
PATH로 지정한 파일에서 XPATH로 선택한 각 요소 / 속성 / 텍스트의 텍스트가 Python 방식2 정규식 PATTERN과 일치하는지 검사합니다.
matchesraw
사용법:
//@ matchesraw PATH PATTERN
PATH로 지정한 파일의 내용이 Python 방식2 정규식 PATTERN과 일치하는지 검사합니다.
count
사용법:
//@ count PATH XPATH COUNT
PATH로 지정한 파일 안에서 XPATH에 대한 일치 항목이 정확히 COUNT개인지 검사합니다.
snapshot
사용법:
//@ snapshot NAME PATH XPATH
PATH로 지정한 파일에서 XPATH로 선택한 요소 / 텍스트가 FILE_STEM.NAME.html 파일에 미리 기록된 서브트리 또는 텍스트(“스냅숏”)와 일치하는지 검사합니다. 여기서 FILE_STEM은 테스트 파일의 파일 스템입니다.
현재 서브트리/텍스트를 기댓값으로 인정하려면 compiletest에 --bless 옵션을 전달하십시오. 이렇게 하면 앞에서 언급한 파일을 덮어씁니다(파일이 없으면 새로 만듭니다). 채널에 따라 달라지는 URL https://doc.rust-lang.org/CHANNEL은 특수 문자열 {{channel}}로 자동 정규화됩니다.
has-dir
사용법:
//@ has-dir PATH
PATH로 지정한 디렉터리가 존재하는지 검사합니다.
files
사용법:
//@ files PATH ENTRIES
PATH로 지정한 디렉터리에 정확히 ENTRIES만 들어 있는지 검사합니다. ENTRIES는 따옴표로 감싼 문자열 안에 적은 Python 방식의 문자열 리스트입니다.
예시: //@ files "foo/bar" '["index.html", "sidebar-items.js"]'
Compiletest 지시문(요약)
소개에서 언급했듯이 compiletest 지시문도 사용할 수 있습니다. 무엇보다도 이 지시문을 사용하면 보조 크레이트를 등록하고, 테스트 대상인 rustdoc 바이너리에 플래그를 전달할 수 있습니다. 아직 지시문에 대해 전혀 모른다면 해당 장을 읽어 보기를 강력히 권장합니다.
다음은 특히 이 테스트 스위트에 해당하는 몇 가지 세부 사항입니다.
rustdoc에 플래그를 전달할 때//@ compile-flags와//@ doc-flags를 모두 사용할 수 있지만, 의도를 드러내려면 후자를 사용하십시오. 전자는rustc를 위한 것입니다.- 보조 크레이트가 있는 테스트 파일에
//@ build-aux-docs를 추가하여 보조 크레이트를rustc로 컴파일할 뿐만 아니라rustdoc로 문서화도 하도록 하십시오.
주의 사항
요소나 텍스트 조각이 없음을 테스트하는 방식은 상당히 취약하며 향후 변경에 잘 대비하지 못합니다.
생성된 HTML 문서 트리의 형태가 이따금 바뀌는 것은 드문 일이 아닙니다. 예를 들어 CSS 클래스 이름이 바뀌는 경우가 여기에 해당합니다.
그런 일이 생기면 긍정 검사는 (XPath 표현식이 충분히 일반적이거나 느슨하다면) 의도한 요소 / 속성 / 텍스트에 계속 일치하여 올바른 대상을 계속 테스트하거나, 그렇지 않으면 실패하여 변경 작성자가 해당 검사를 살펴볼 수밖에 없게 만듭니다.
반면 부정 검사(예: //@ !has PATH XPATH PATTERN)는 XPath 표현식이 “더 이상” 일치하지 않아도 실패하지 않습니다. 따라서 “형태“를 바꾼 작성자는 알림을 받지 못하며, 그 결과 다른 누군가가 생성된 문서에 PATTERN을 의도치 않게 다시 도입하더라도 원래의 부정 검사는 실패하지 않습니다.
참고: 부정 검사는 사용하지 마십시오!
팁: 피할 수 없다면, “형태“를 바꾸는 사람들이 이를 알아차리고 부정 검사를 갱신할 수 있도록 반드시 바로 가까이에 그에 대응하는 긍정 검사를 함께 두십시오!
제한 사항
HtmlDocCk는 Python 표준 라이브러리의 XPath 구현을 사용합니다. 이로 인해 다음과 같은 몇 가지 제한 사항이 있습니다.
- 구현상의 결함 때문에 모든
XPATH인자는//로 시작해야 합니다. - 많은 XPath 기능(함수, 축 등)은 지원되지 않습니다.
- 적격 HTML만 파싱할 수 있습니다(rustdoc이 짝이 맞지 않는 태그를 출력하지 않기를 바랍니다).
또한 compiletest 리비전은 지원되지 않습니다.
rustdoc-gui 테스트 스위트
FIXME: 이 절은 아직 초안 상태입니다. 내용을 채우는 데 도움을 주십시오!
이 페이지는 rustdoc의 “GUI”(즉, 브라우저에서 렌더링되는 HTML/JS/CSS)를 테스트하는 데 사용되는 rustdoc-gui 테스트 스위트를 다룹니다. 그 밖의 rustdoc 전용 테스트 스위트에 대해서는 Rustdoc 테스트 스위트를 참고하십시오.
이 테스트들은 browser-UI-test라는 NodeJS 기반 도구를 사용하며, 이 도구는 puppeteer를 사용해 헤드리스 브라우저에서 테스트를 실행하고 렌더링과 상호작용을 검사합니다. 이러한 형태의 테스트를 작성하는 방법은 tests/rustdoc-gui/README.md와 .goml 형식에 대한 설명을 참고하십시오.
rustdoc-json 테스트 스위트
이 페이지는 rustdoc의 json 출력을 테스트하는 rustdoc-json이라는 테스트 스위트만을 다룹니다. rustdoc 테스트에 사용되는 다른 테스트 스위트에 대해서는 §Rustdoc 테스트 스위트를 참조하십시오.
테스트는 compiletest로 실행되며, 일반적인 지시문을 모두 사용할 수 있습니다. 여기에서 자주 사용되는 지시문은 다음과 같습니다.
- 의존성을 두기 위한
//@ aux-build //@ edition: 2021(또는 다른 에디션)- 비공개 항목 문서화를 활성화하기 위한
//@ compile-flags: --document-hidden-items
각 크레이트의 json 출력은 jsondoclint와 jsondocck라는 2개의 프로그램으로 검사됩니다.
jsondoclint
jsondoclint는 모든 Id가 index(또는 paths)에 존재하는지 검사합니다. 이를 통해 댕글링(dangling) Id가 없음을 보장합니다.
jsondocck
jsondocck는 주석에 주어진 지시문을 처리하여 출력의 값이 예상한 대로인지 단언합니다. 그런 점에서 htmldocck와 매우 비슷합니다.
쿼리 언어로 JSONPath를 사용하는데, 이는 경로를 받아 그 경로와 일치한다고 하는 값들의 목록을 반환합니다.
지시문
//@ has <path>:<path>가 존재하는지, 즉 최소 1개의 값과 일치하는지 검사합니다.//@ !has <path>:<path>가 존재하지 않는지, 즉 0개의 값과 일치하는지 검사합니다.//@ has <path> <value>:<path>가 존재하고, 일치하는 값 중 최소 1개가 주어진<value>와 같은지 검사합니다.//@ !has <path> <value>:<path>가 존재하지만, 일치하는 값 중 어느 것도 주어진<value>와 같지 않은지 검사합니다.//@ is <path> <value>:<path>가 정확히 하나의 값과 일치하고, 그 값이 주어진<value>와 같은지 검사합니다.//@ is <path> <value> <value>...:<path>가 주어진 모든<value>와 정확히 일치하는지 검사합니다. 여기서 순서는 중요하지 않습니다.//@ !is <path> <value>:<path>가 정확히 하나의 값과 일치하고, 그 값이 주어진<value>와 같지 않은지 검사합니다.//@ count <path> <number>:<path>가<number>개의 값과 일치하는지 검사합니다.//@ set <name> = <path>:<path>가 정확히 하나의 값과 일치하는지 검사하고, 그 값을<name>이라는 변수에 저장합니다.
이들은 directive.rs에 정의되어 있습니다.
값
값은 JSON 값이거나 변수일 수 있습니다.
-
JSON 값은 JSON 리터럴입니다. 예를 들면
true,"string",{"key": "value"}등이 있습니다. 이러한 값이 하나의 값으로 처리되려면'로 감싸야 하는 경우가 많습니다. §인자 분할을 참조하십시오. -
변수를 사용하면 한 경로의 값을 저장해 두었다가 이후 쿼리에서 사용할 수 있습니다. 변수는
//@ set <name> = <path>지시문으로 설정하며,$<name>으로 접근합니다.#![allow(unused)] fn main() { //@ set foo = $some.path //@ is $.some.other.path $foo }
인자 분할
지시문의 인자는 POSIX 셸 이스케이프를 구현하는 shlex 크레이트를 사용하여 분할됩니다. 이는 지시문에 전달되는 <path> 인자와 <value> 인자 모두에 공백과 따옴표가 함께 들어 있는 경우가 많기 때문입니다.
$.index[?(@.docs == "foo")].some.field라는 <path>와 "bar"라는 값1으로 @ is를 사용하려면 다음과 같이 작성합니다.
#![allow(unused)]
fn main() {
//@ is '$.is[?(@.docs == "foo")].some.field' '"bar"'
}
-
값은 JSON 문자열 값이어야 하므로 shlex 분할 이후에
"bar"가 되어야 합니다. ↩
std::offload
이 모듈은 현재 활발히 개발 중입니다. 업스트림에 반영되면 Rust 개발자는 GPU에서 Rust 코드를 실행할 수 있게 됩니다. 저희는 기본적으로 안전하고 편리하며 충분히 빠른 rusty GPU 프로그래밍 인터페이스를 개발하는 것을 목표로 합니다. 여기에는 GPU와 주고받는 데이터를 효율적으로 자동 이동하는 기능이 포함됩니다. (나중에는) 더 높은 수준의 제어가 가능한 고급 인터페이스도 제공할 예정이며, 이 인터페이스는 안전하지 않을 수도 있습니다.
이 구현은 LLVM의 “offload” 프로젝트를 기반으로 합니다. 이 프로젝트는 OpenMP가 GPU에서 Fortran이나 C++ 코드를 실행하는 데 이미 사용하고 있습니다. 프로젝트가 개발 중인 동안에는 사용자가 컴파일 과정을 마치려면 clang 같은 다른 컴파일러를 호출해야 합니다.
상위 수준 컴파일 설계:
저희는 단일 소스, 3패스 컴파일 방식을 사용합니다.
먼저 호스트 코드(예: x86-64)를 컴파일하여, 호스트 코드에 필요한 커널 인스턴스화를 제네릭 인스턴스화까지 포함해 파악합니다. 이 패스는 코드 생성을 수행하지 않습니다. 대신 호스트 코드에 필요한 모든 #[offload_kernel] 인스턴스를 (구체적인 제네릭 인자와 함께) 기록한 매니페스트를 작성합니다.
그다음 디바이스(예: nvptx64, amdgcn-amd-amdhsa, 향후에는 intel)로 오프로드해야 하는 모든 함수를 컴파일하며, 이때 -Zoffload=Device=<manifest>를 통해 매니페스트를 전달합니다. 기록된 커널 인스턴스는 단형화(monomorphization) 루트로 추가되므로, 필요한 제네릭 커널에 대해 코드 생성이 이루어집니다. 현재는 번거로운 #cfg(target_os="") 어노테이션이 필요하지만, 향후에는 오프로드 인트린식을 기반으로 이를 인식할 계획입니다. 이 디바이스 컴파일은 현재 rustc 내부의 쿼리 시스템을 활용하지 않으므로, 지금은 커널을 항상 다시 컴파일합니다. 이 문제는 쉽게 고칠 수 있겠지만, 현재는 기능 추가와 런타임 성능 개선을 우선하고 있습니다. 다만 직접 구현하고 싶으시다면 언제든 연락해 주십시오!
그다음 호스트(예: x86-64)용 코드를 컴파일하며, 오프로딩 로직의 대부분이 이 단계에서 이루어집니다. 호스트 측에서는 openmp 오프로드 런타임을 호출하는 코드를 생성하여 런타임에 타입의 레이아웃(autodiff TypeTree의 단순화된 버전)을 알려 줍니다. 또한 타입 시스템을 사용하여 커널 인자를 디바이스로만 옮겨야 하는지(예: &[f32;1024]), 디바이스에서만 옮겨야 하는지, 아니면 양방향으로 옮겨야 하는지(예: &mut [f64])를 판단합니다. 그런 다음 커널을 실행하고, 실행이 끝나면 런타임에 이 환경을 종료하고 데이터를 (필요한 만큼) 다시 옮기도록 알립니다.
호스트를 위한 세 번째 패스는 디바이스 컴파일에서 생성된 커널 산출물을 불러옵니다. rustc는 일반적으로 빌드 디렉터리 구조를 “추측“하거나 하드코딩해서는 안 되므로, 각 호출에서 커널 산출물과 매니페스트의 경로를 전달받아야 합니다. 이 로직을 cargo에 통합할 수도 있지만, 간단한 cargo 래퍼만 있으면 충분합니다. 더 널리 채택되기 전까지는 이 래퍼를 crates.io를 통해 손쉽게 제공할 수 있습니다.
단일 소스, 단일 패스 컴파일 방식을 고려하고 싶어질 수도 있습니다. 하지만 rustc 프런트엔드(예: AST)의 상당 부분은 죽은 코드(예: 비활성화된 cfg 뒤의 코드)를 모두 제거합니다. 프런트엔드가 두 타깃의 코드를 단순하게 확장하고 로워링(lowering)하도록 하면 같은 심볼이 여러 번 정의되는 문제(및 기타 문제)가 발생합니다. 이제 어떤 심볼이든 두 가지 구현을 가질 수 있다는 점을 rustc의 미들엔드와 백엔드 전체가 인식하도록 만드는 것은 큰 작업입니다. 대안이 약 5줄짜리 cargo 래퍼라면 굳이 컴파일러 전체를 더 복잡하게 만들어야 하는지 의문입니다. 이 방식에서도 저희가 전체 컴파일 파이프라인을 제어하며 호스트 코드와 디바이스 코드를 모두 사용할 수 있으므로, 두 방식 사이에 런타임 성능 차이는 없을 것입니다.
설치
std::offload는 사용자용 nightly 빌드에서 부분적으로 사용할 수 있습니다. 하지만 현재로서는 모든 기능을 사용하려면 누구나 여전히 rustc를 소스에서 직접 빌드해야 합니다.
Rustup 설치.
x86_64 Linux를 사용 중이라면 다음 명령으로 nightly 툴체인을 설치할 수 있습니다.
rustup +nightly component add offload
빌드 방법
그렇지 않다면 Rust 저장소를 클론하고 구성해야 합니다:
git clone git@github.com:rust-lang/rust
cd rust
./configure --enable-llvm-link-shared --release-channel=nightly --enable-llvm-assertions --enable-llvm-offload --enable-llvm-enzyme --enable-clang --enable-lld --enable-option-checking --enable-ninja --disable-docs
그런 다음 다음 명령으로 rustc를 빌드할 수 있습니다:
./x build --stage 1 library
그런 다음 rustc toolchain link를 사용하면 cargo를 통해 이 툴체인을 사용할 수 있습니다:
rustup toolchain link offload build/host/stage1
rustup toolchain install nightly # enables -Z unstable-options
LLVM 자체의 빌드 방법
git clone git@github.com:llvm/llvm-project
cd llvm-project
mkdir build
cd build
cmake -G Ninja ../llvm -DLLVM_TARGETS_TO_BUILD="host;AMDGPU;NVPTX" -DLLVM_ENABLE_ASSERTIONS=ON -DLLVM_ENABLE_PROJECTS="clang;lld" -DLLVM_ENABLE_RUNTIMES="offload;openmp" -DLLVM_ENABLE_PLUGINS=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=.
ninja
ninja install
이렇게 하면 작동하는 LLVM 빌드를 얻을 수 있습니다.
테스트
offload 관련 테스트를 실행하려면 이 테스트 스크립트를 실행하십시오:
./x test --stage 1 tests/codegen-llvm/gpu_offload
CI를 로컬에서 테스트하려면 Docker로 테스트하기에 설명된 명령을 사용할 수 있습니다:
cargo run --manifest-path src/ci/citool/Cargo.toml run-local dist-x86_64-linux
이 명령은 모든 컴파일러 산출물을 obj 디렉터리에 저장합니다. 하지만 rustc 관련 코드를 수정한 경우에는 이 디렉터리를 삭제해야 할 수 있습니다. 그렇게 하지 않으면 Docker 이미지가 이전 상태를 캐시하기 때문입니다.
이 시점에서 서브모듈도 체크아웃되어 있어야 합니다.
사용법
이 기능은 아직 개발 중이며 사용할 수 있는 단계가 아닙니다. 이 안내는 기여자나 최신 진행 상황을 확인하고자 하는 분들을 위한 것입니다. 현재 다음 Rust 커널을 GPU에서 실행하는 작업을 진행하고 있습니다. 따라 해 보시려면 이 코드를 src/lib.rs 파일에 복사하십시오.
#![allow(internal_features)]
#![feature(gpu_offload)]
#![cfg_attr(target_os = "linux", feature(core_intrinsics))]
#![cfg_attr(target_arch = "amdgpu", feature(stdarch_amdgpu, abi_gpu_kernel))]
#![cfg_attr(target_arch = "nvptx64", feature(stdarch_nvptx, abi_gpu_kernel))]
#![no_std]
#[cfg(target_os = "linux")]
extern crate libc;
use core::offload::offload_kernel;
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
loop {}
}
#[cfg(target_arch = "amdgpu")]
use core::arch::amdgpu::{workgroup_id_x as block_idx_x, workitem_id_x as thread_idx_x};
#[cfg(target_arch = "nvptx64")]
use core::arch::nvptx::{
_block_dim_x as block_dim_x, _block_idx_x as block_idx_x, _thread_idx_x as thread_idx_x,
};
// Kernels can be generic, like any other Rust function.
// The concrete instantiations required by the host code are collected in a
// manifest, which the device compilation then reads (see below).
#[offload_kernel]
fn kernel<T: Copy>(x: *mut [T; 256], value: T) {
unsafe {
let n = (*x).len();
let i = (thread_idx_x() + block_idx_x() * block_dim_x()) as usize;
if i < n {
(*x)[i] = value;
}
}
}
#[cfg(target_os = "linux")]
#[unsafe(no_mangle)]
fn main() {
let mut x = [0.0f64; 256];
core::offload::offload! {
kernel = kernel,
workgroup_dim = [256, 1, 1],
args = (&mut x as *mut [f64; 256], 2.5),
}
for i in 0..x.len() {
assert_eq!(x[i], 2.5);
}
unsafe { libc::printf(c"all checks passed".as_ptr()); }
}
컴파일 방법
rustc와 같은 LLVM으로 빌드된 clang 컴파일러를 사용하는 것이 중요합니다. 전체 경로 없이 clang만 호출하면 시스템에 설치된 clang이 사용될 가능성이 높으며, 이 clang은 호환되지 않을 가능성이 큽니다. 따라서 아래의 clang/lld 호출을 절대 경로로 바꾸거나, PATH를 그에 맞게 설정하십시오.
컴파일은 세 개의 패스로 진행됩니다:
HostMetadata: 호스트 코드를 컴파일하고, 호스트 코드에 필요한 커널 인스턴스화(제네릭 인스턴스화 포함)를 나열한 매니페스트를 작성합니다.Device: GPU용 커널을 컴파일하며, 매니페스트를 읽어 기록된 제네릭 인스턴스화가 코드 생성되도록 합니다.Host: 디바이스 산출물을 포함하는 최종 호스트 코드를 생성합니다.
target-cpu(gfx90a)를 사용하시는 GPU에 맞는 코드로 바꾸십시오. 이러한 코드는 흔히 “LLVM 타깃 이름”1이라고 불립니다.
먼저 호스트 코드로부터 매니페스트를 생성합니다:
RUSTFLAGS="--emit=llvm-bc,llvm-ir -Csave-temps -Zoffload=HostMetadata=/absolute/path/to/offload.manifest -Zunstable-options" cargo +offload build -r
이 패스는 매니페스트만 작성합니다.
이제 매니페스트를 전달하여 디바이스(GPU) 코드를 생성합니다:
RUSTFLAGS="-Ctarget-cpu=gfx90a --emit=llvm-bc,llvm-ir -Zoffload=Device=/absolute/path/to/offload.manifest -Csave-temps -Zunstable-options" cargo +offload build -Zunstable-options -r -v --target amdgcn-amd-amdhsa -Zbuild-std=core
다음으로 호스트(CPU) 코드를 생성합니다.
RUSTFLAGS="--emit=llvm-bc,llvm-ir -Csave-temps -Zoffload=Host=$PWD/target/amdgcn-amd-amdhsa/release/deps/device.bin -Zunstable-options" cargo +offload build -r
마지막 단계로, 이제 바이너리를 실행할 수 있습니다
LD_LIBRARY_PATH=$(rustc +nightly --print sysroot)/lib ./target/x86_64-unknown-linux-gnu/release/binary-name
all checks passed!
모든 단계를 테스트할 시간이 더 확보되면 이 세 단계는 곧 하나의 명령으로 통합될 예정입니다.
메모리 전송에 대한 더 자세한 정보를 받으려면 바이너리 호출 앞에 LIBOMPTARGET_INFO=-1을 추가하여 정보 출력을 활성화할 수 있습니다.
-
https://rocm.docs.amd.com/en/latest/reference/gpu-arch-specs.html 또는 https://developer.nvidia.com/cuda/gpus. 또는
rustc --print target-cpus를 확인하십시오. ↩
기여하기
기여는 언제나 환영합니다. 이 프로젝트는 실험적이므로 문서와 코드가 불완전할 가능성이 높습니다. 진행이 막히거나 문서가 명확하지 않다면 Zulip(권장) 또는 Rust Community Discord에서 도움을 요청하십시오.
저희는 일반적으로 사용자를 위해 컴파일 과정을 최대한 자동화하려고 합니다. 하지만 기여자 입장에서는 rustc를 반복해서 다시 컴파일할 필요 없이 LLVM-IR 모듈(.ll)을 직접 수정하고 컴파일하여 변경 사항을 빠르게 반복 적용하는 편이 때로는 더 쉬울 수 있습니다. 따라서 LLVM에 익숙한 분들을 위해 아래의 셸 스크립트를 제공합니다. IR 변경 사항이 만족스러울 때에만 원하는 새 출력을 생성하도록 rustc를 업데이트하는 작업을 진행할 수 있습니다.
set -e
# set -e to avoid continuing on errors, which would likely use stale artifacts
# inputs:
# lib.ll (host code) + host.out (device)
# You only need to run the commands below once to generate lib.ll and host.out from your rust code.
# RUSTFLAGS="--emit=llvm-bc,llvm-ir -Csave-temps -Zoffload=HostMetadata=/absolute/path/to/offload.manifest -Zunstable-options" cargo +offload build -r
#
# RUSTFLAGS="-Ctarget-cpu=gfx90a --emit=llvm-bc,llvm-ir -Zoffload=Device=/absolute/path/to/offload.manifest -Csave-temps -Zunstable-options" cargo +offload build -Zunstable-options -v --target amdgcn-amd-amdhsa -Zbuild-std=core -r
#
# RUSTFLAGS="--emit=llvm-bc,llvm-ir -Csave-temps -Zoffload=Host=/absolute/path/to/project/target/amdgcn-amd-amdhsa/release/deps/host.out -Zunstable-options" cargo +offload build -r
#
# cp target/release/deps/<project_name>.ll lib.ll
opt lib.ll -o lib.bc
"clang-21" "-cc1" "-triple" "x86_64-unknown-linux-gnu" "-S" "-save-temps=cwd" "-disable-free" "-clear-ast-before-backend" "-main-file-name" "lib.rs" "-mrelocation-model" "pic" "-pic-level" "2" "-pic-is-pie" "-mframe-pointer=all" "-fmath-errno" "-ffp-contract=on" "-fno-rounding-math" "-mconstructor-aliases" "-funwind-tables=2" "-target-cpu" "x86-64" "-tune-cpu" "generic" "-resource-dir" "/<path>/rust/build/x86_64-unknown-linux-gnu/llvm/lib/clang/21" "-ferror-limit" "19" "-fopenmp" "-fopenmp-offload-mandatory" "-fgnuc-version=4.2.1" "-fskip-odr-check-in-gmf" "-fembed-offload-object=host.out" "-fopenmp-targets=amdgcn-amd-amdhsa" "-faddrsig" "-D__GCC_HAVE_DWARF2_CFI_ASM=1" "-o" "host.s" "-x" "ir" "lib.bc"
"clang-21" "-cc1as" "-triple" "x86_64-unknown-linux-gnu" "-filetype" "obj" "-main-file-name" "lib.rs" "-target-cpu" "x86-64" "-mrelocation-model" "pic" "-o" "host.o" "host.s"
"/<path>/rust/build/x86_64-unknown-linux-gnu/llvm/bin/clang-linker-wrapper" "--should-extract=gfx90a" "--device-compiler=amdgcn-amd-amdhsa=-g" "--device-compiler=amdgcn-amd-amdhsa=-save-temps=cwd" "--device-linker=amdgcn-amd-amdhsa=-lompdevice" "--host-triple=x86_64-unknown-linux-gnu" "--save-temps" "--linker-path=/<path>/rust/build/x86_64-unknown-linux-gnu/lld/bin/ld.lld" "--hash-style=gnu" "--eh-frame-hdr" "-m" "elf_x86_64" "-pie" "-dynamic-linker" "/lib64/ld-linux-x86-64.so.2" "-o" "a.out" "/lib/../lib64/Scrt1.o" "/lib/../lib64/crti.o" "/opt/rh/gcc-toolset-12/root/usr/lib/gcc/x86_64-redhat-linux/12/crtbeginS.o" "-L/<path>/rust/build/x86_64-unknown-linux-gnu/llvm/bin/../lib/x86_64-unknown-linux-gnu" "-L/<path>/rust/build/x86_64-unknown-linux-gnu/llvm/lib/clang/21/lib/x86_64-unknown-linux-gnu" "-L/opt/rh/gcc-toolset-12/root/usr/lib/gcc/x86_64-redhat-linux/12" "-L/opt/rh/gcc-toolset-12/root/usr/lib/gcc/x86_64-redhat-linux/12/../../../../lib64" "-L/lib/../lib64" "-L/usr/lib64" "-L/lib" "-L/usr/lib" "host.o" "-lstdc++" "-lm" "-lomp" "-lomptarget" "-L/<path>/rust/build/x86_64-unknown-linux-gnu/llvm/lib" "-lgcc_s" "-lgcc" "-lpthread" "-lc" "-lgcc_s" "-lgcc" "/opt/rh/gcc-toolset-12/root/usr/lib/gcc/x86_64-redhat-linux/12/crtendS.o" "/lib/../lib64/crtn.o"
LIBOMPTARGET_INFO=-1 OFFLOAD_TRACK_ALLOCATION_TRACES=true ./a.out
clang-linker-wrapper 호출에 있는 <path> 자리 표시자를 수정하십시오. 라이브러리 경로도 조정해야 할 가능성이 높습니다. 자세한 내용은 링크된 사용법 섹션을 참고하십시오: 사용법
Rust의 std::autodiff 모듈을 사용하면 미분 가능 프로그래밍(differentiable programming)을 할 수 있습니다:
#![feature(autodiff)]
use std::autodiff::*;
// f(x) = x * x, f'(x) = 2.0 * x
// bar therefore returns (x * x, 2.0 * x)
#[autodiff_reverse(bar, Active, Active)]
fn foo(x: f32) -> f32 { x * x }
fn main() {
assert_eq!(bar(3.0, 1.0), (9.0, 6.0));
assert_eq!(bar(4.0, 1.0), (16.0, 8.0));
}
std::autodiff 모듈에 대한 자세한 문서는 std::autodiff에서 확인할 수 있습니다.
미분 가능 프로그래밍은 수치 계산, 고체 역학, 계산 화학, 유체 역학 등 다양한 분야에서 사용되며, 역전파를 통한 신경망 학습, ODE 솔버, 미분 가능 렌더링, 양자 컴퓨팅, 기후 시뮬레이션에도 사용됩니다.
std::autodiff는 현재 자동 미분을 위한 LLVM 기반 도구인 Enzyme을 기반으로 합니다. 컴파일러 기반 autodiff를 사용하는 주된 이유는 세 가지입니다.
- 사용성: 현재의 autodiff 크레이트는 일반적인 Rust 프로그램을 지원하지 않습니다. 이들 크레이트는 사용자 정의 DSL을 강제하거나, (슬라이스나 배열 등 대신) 라이브러리가 제공하는 타입의 사용을 요구하거나, 스칼라 함수로만 제한됩니다. 컴파일러 기반 autodiff를 사용하면 배열, 슬라이스, 사용자 정의 구조체와 열거형, 제어 흐름 등을 포함한 일반적인 Rust 코드를 작성할 수 있습니다.
- 성능: 기존 Rust autodiff 접근 방식은 대부분 연산마다 일정한 오버헤드가 있습니다. 이 오버헤드는 큰 텐서에 대해 비용이 큰 연산을 적은 횟수로 수행하는 ML 애플리케이션에서는 쉽게 상쇄될 수 있습니다. 그러나 HPC나 과학 계산 분야의 애플리케이션에서는 받아들일 수 없는 경우가 많습니다. 컴파일러 기반 autodiff는 (최적화된) LLVM IR을 대상으로 동작하므로 이러한 경우에 훨씬 더 나은 성능을 낼 수 있습니다.
- 기능: 이처럼 낮은 수준에서 동작하고 다른 LLVM 기반 언어와 구현을 공유하므로, Enzyme 프로젝트에서 이미 이루어진 방대한 작업을 활용할 수 있습니다. 예를 들어, MPI 루틴을 호출하는 Rust 코드나 CuBLAS 같은 라이브러리를 포함한 GPU 코드를 지원할 수 있습니다.
설치
다음 플랫폼 중 하나를 사용하는 대부분의 사용자는 rustup으로 enzyme 컴포넌트를 설치하여 최신 nightly 툴체인에서 std::autodiff를 활성화할 수 있습니다:
- Linux:
x86_64-unknown-linux-gnu또는aarch64-unknown-linux-gnu - macOS:
aarch64-apple-darwin또는x86_64-apple-darwin - Windows:
x86_64-llvm-mingw또는aarch64-llvm-mingw
rustc/enzyme/autodiff 기여자이거나 다른 플랫폼이 필요한 경우에는 autodiff를 포함한 rustc를 소스에서 빌드할 수 있습니다. 선호하는 타깃에 대한 자동 빌드 활성화에 도움이 필요하면 이슈를 열어 주십시오.
설치 가이드
Linux, macOS 또는 Windows에서 std::autodiff를 사용하려 하고 프로젝트에 PR을 기여할 계획이 없다면, 기존 nightly 설치를 그대로 사용하고 누락된 컴포넌트만 내려받을 것을 권장합니다. 다음을 실행하십시오:
rustup +nightly component add enzyme
이전 버전의 rustup은 이 컴포넌트를 인식하지 못하므로, 문제가 발생하면 rustup 자체를 업데이트해 보십시오:
rustup self update
rustup +nightly component add enzyme
Nix용 설치 가이드
Nix에서는 oxalica rust-overlay를 사용하여 Enzyme 컴포넌트가 포함된 nightly Rust 툴체인을 선언할 수 있습니다.
예를 들면 다음과 같습니다.
rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
extensions = [ "enzyme" ];
})
또는 다음과 같이 Enzyme 컴포넌트를 선언하는 툴체인 파일을 만들고
[toolchain]
channel = "nightly-2026-06-23"
components = [ "enzyme" ]
오버레이에서 이를 사용할 수도 있습니다.
rust-bin.fromRustupToolchainFile ./rust-toolchain.toml
빌드 방법
먼저 Rust 저장소를 클론하고 구성해야 합니다. 필요에 따라 --enable-clang 또는 --enable-lld를 사용할 수도 있습니다.
git clone git@github.com:rust-lang/rust
cd rust
./configure --release-channel=nightly --enable-llvm-enzyme --enable-llvm-link-shared --enable-llvm-assertions --enable-ninja --enable-option-checking --disable-docs --set llvm.download-ci-llvm=false
그런 다음 다음 명령으로 rustc를 빌드할 수 있습니다:
./x build --stage 1 library
그런 다음 rustc 툴체인을 링크하면 cargo를 통해 사용할 수 있습니다.
rustup toolchain link enzyme build/host/stage1
rustup toolchain install nightly # enables -Z unstable-options
이제 테스트 케이스를 실행할 수 있습니다:
./x test --stage 1 tests/codegen-llvm/autodiff
./x test --stage 1 tests/pretty/autodiff
./x test --stage 1 tests/ui/autodiff
./x test --stage 1 tests/run-make/autodiff
./x test --stage 1 tests/ui/feature-gates/feature-gate-autodiff.rs
Autodiff는 아직 실험적 기능이므로, 자신의 프로젝트에서 사용하려면 Cargo.toml에 lto="fat"를 추가하고 cargo 또는 cargo +nightly 대신 RUSTFLAGS="-Zautodiff=Enable" cargo +enzyme를 사용해야 합니다.
컴파일러 익스플로러 및 dist 빌드
저희 컴파일러 익스플로러 인스턴스도 비슷한 방식으로 더 새로운 rustc로 업데이트할 수 있습니다. 먼저 docker 인스턴스를 준비하십시오.
docker run -it ubuntu:22.04
export CC=clang CXX=clang++
apt update
apt install wget vim python3 git curl libssl-dev pkg-config lld ninja-build cmake clang build-essential
그런 다음 약간 변경된 방식으로 rustc를 빌드하십시오:
git clone https://github.com/rust-lang/rust
cd rust
./configure --release-channel=nightly --enable-llvm-enzyme --enable-llvm-link-shared --enable-llvm-assertions --enable-ninja --enable-option-checking --disable-docs --set llvm.download-ci-llvm=false
./x dist
그런 다음 tarball을 호스트로 복사합니다. dockerid는 docker ps -a 목록에서 가장 최근 항목입니다.
docker cp <dockerid>:/rust/build/dist/rust-nightly-x86_64-unknown-linux-gnu.tar.gz rust-nightly-x86_64-unknown-linux-gnu.tar.gz
그런 다음 EnzymeAD/rust 저장소에 새 (사전 릴리스) 태그를 만들고, 태그를 갱신하도록 EnzymeAD/enzyme-explorer 저장소에 PR을 올릴 수 있습니다. tgymnich가 업데이트 스크립트를 실행할 수 있도록 PR에서 tgymnich를 멘션하는 것을 잊지 마십시오. 참고: EnzymeAD/rust를 보관(archive) 처리하고 이곳의 안내를 갱신해야 합니다. 곧 explorer가 공식 rust 서버에서 rustc 툴체인을 가져올 수 있게 될 것입니다.
Enzyme 자체의 빌드 안내
위의 Rust 빌드 안내를 따르면 Rust 컴파일러와 함께 LLVMEnzyme, LLDEnzyme, ClangEnzyme이 빌드됩니다. 이들 중 하나를 사용하기만 하려 하고 cmake를 다뤄 본 경험이 없다면 이 방법을 권장합니다. 하지만 Rust 없이 Enzyme만 빌드하고 싶다면 다음 안내가 도움이 될 수 있습니다.
git clone git@github.com:llvm/llvm-project
cd llvm-project
mkdir build
cd build
cmake -G Ninja ../llvm -DLLVM_TARGETS_TO_BUILD="host" -DLLVM_ENABLE_ASSERTIONS=ON -DLLVM_ENABLE_PROJECTS="clang;lld" -DLLVM_ENABLE_RUNTIMES="openmp" -DLLVM_ENABLE_PLUGINS=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=.
ninja
ninja install
이제 동작하는 LLVM 빌드가 준비되었으니, Enzyme 빌드를 계속 진행할 수 있습니다. llvm-project 폴더에서 나온 뒤 다음 명령을 실행하십시오.
git clone git@github.com:EnzymeAD/Enzyme
cd Enzyme/enzyme
mkdir build
cd build
cmake .. -G Ninja -DLLVM_DIR=<YourLocalPath>/llvm-project/build/lib/cmake/llvm/ -DLLVM_EXTERNAL_LIT=<YourLocalPath>/llvm-project/llvm/utils/lit/lit.py -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=YES -DBUILD_SHARED_LIBS=ON
ninja
이렇게 하면 Enzyme이 빌드되며, Enzyme/enzyme/build/lib/<LLD/Clang/LLVM/lib>Enzyme.so에서 찾을 수 있습니다. (확장자는 운영 체제에 따라 다를 수 있습니다.)
백엔드 크래시 보고하기
컴파일이 실패한 뒤 대량의 llvm-ir 코드가 출력된다면 enzyme 백엔드가 코드를 컴파일하지 못했을 가능성이 높습니다. 이러한 경우는 디버깅하기가 더 어려우므로 여러분의 도움에 크게 감사드립니다. 또한 현재로서는 대개 릴리스 빌드가 동작할 가능성이 훨씬 높다는 점도 염두에 두십시오.
여기서 최종 목표는 enzyme 컴파일러 익스플로러에서 버그를 재현하여 Enzyme 저장소에 버그 보고를 작성하는 것입니다.
이를 돕기 위해 rustflags에 전달할 수 있는 autodiff 플래그가 있습니다. 이 플래그는 전체 llvm-ir 모듈을 몇 개의 __enzyme_fwddiff 또는 __enzyme_autodiff 호출과 함께 출력합니다. Linux에서 가능한 워크플로는 다음과 같습니다:
llvm-ir 생성 제어하기
llvm-ir을 생성하기 전에, 관련 rust 코드가 디버깅할 때 확실히 보이도록 하는 데 도움이 되는 두 가지 기법을 염두에 두십시오.
std::hint::black_box: rust 변수나 표현식을std::hint::black_box()로 감싸 rust와 llvm이 이를 최적화하여 제거하지 못하게 하십시오. 이는 llvm-ir에서 특정 값을 검사하거나 수동으로 조작해야 할 때 유용합니다.extern "rust"또는extern "c": 특정 함수 선언이 llvm-ir로 어떻게 낮춰지는지 보고 싶다면 해당 함수를extern "rust"또는extern "c"로 선언할 수 있습니다. 생성된 모듈 안에서 기존의__enzyme_autodiff또는 유사한 선언을 찾아 예시로 참고할 수도 있습니다.
1) llvm-ir 재현 파일 생성하기
RUSTFLAGS="-Z autodiff=Enable,PrintModBefore" cargo +enzyme build --release &> out.ll
이 방법은 모듈 위아래에 있는 몇 가지 경고와 정보 메시지도 함께 캡처합니다. out.ll을 열고 ; moduleid = <somehash> 위에 있는 모든 줄을 제거하십시오. 이제 파일의 끝부분을 보고 llvm-ir에 속하지 않는 모든 내용, 즉 오류와 경고를 제거하십시오. 이제 llvm-ir의 마지막 줄은 !<somenumber> = 로 시작해야 합니다. 예를 들면 !40831 = !{i32 0, i32 1037508, i32 1037538, i32 1037559} 또는 !43760 = !dilocation(line: 297, column: 5, scope: !43746)와 같습니다.
실제 숫자는 코드에 따라 달라집니다.
2) llvm-ir 재현 파일 확인하기
이전 단계가 제대로 되었는지 확인하기 위해 llvm의 opt 도구를 사용하겠습니다. <some_dir>/rust/build/<x86/arm/...-target-triple>/ci-llvm/bin/opt와 비슷한 opt 바이너리 경로를 찾으십시오. LLVM을 소스에서 빌드한다면 ci-llvm을 build로 바꿔야 할 가능성이 높습니다. 또한 /rust/build/target-triple/enzyme/build/enzyme/llvmenzyme-21과 비슷한 llvmenzyme-21.<so/dll/dylib> 경로도 찾으십시오. llvm은 llvm 백엔드를 자주 업데이트하므로 버전 번호가 더 높을 수 있다는 점(20, 21, …)을 염두에 두십시오. 둘 다 찾았다면 다음 명령을 실행하십시오.
<path/to/opt> out.ll -load-pass-plugin=/path/to/build/<target-triple>/stage1/lib/libEnzyme-21.so -passes="enzyme" -enzyme-strict-aliasing=0 -S
이 명령은 향후 버전이나 여러분의 시스템에서 실패할 수 있으며, 그런 경우에는 libEnzyme-21.so를 LLVMEnzyme-21.so로 바꿔야 합니다. 빌드 방법에 대한 안내는 Enzyme 문서를 참고하십시오. LLVM 버전을 빌드하는 방법도 조정해야 할 수 있습니다.
이전 단계가 성공했다면 cargo로 rust 코드를 컴파일할 때 보았던 것과 같은 오류가 표시됩니다.
같은 오류가 나타나지 않는다면 rust 저장소에 이슈를 열어 주십시오. 성공했다면 축하합니다! 파일이 여전히 매우 크므로 자동으로 최소화해 봅시다.
3) llvm-ir 재현 코드 최소화하기
먼저 llvm-extract 바이너리를 찾으십시오. opt 바이너리와 같은 폴더에 있을 것입니다. 그런 다음 다음을 실행하십시오:
<path/to/llvm-extract> -S --func=<name> --recursive --rfunc="enzyme_autodiff*" --rfunc="enzyme_fwddiff*" --rfunc=<fnc_called_by_enzyme> out.ll -o mwe.ll
이 명령은 최소 작동 예제인 mwe.ll을 생성합니다.
마지막 --func 플래그로 전달하는 이름을 알맞게 바꾸십시오. 미분하려는 함수에 #[no_mangle] 속성을 적용한 뒤 해당 이름을 Rust 이름으로 바꿀 수 있습니다. 그렇지 않으면 맹글링된 함수 이름을 찾아야 합니다. 이를 위해 out.ll을 열고 __enzyme_fwddiff 또는 __enzyme_autodiff를 검색하십시오. 해당 함수 호출의 첫 번째 문자열이 여러분의 함수 이름입니다. 예시:
define double @enzyme_opt_helper_0(ptr %0, i64 %1, double %2) {
%4 = call double (...) @__enzyme_fwddiff(ptr @_zn2ad3_f217h3b3b1800bd39fde3e, metadata !"enzyme_const", ptr %0, metadata !"enzyme_const", i64 %1, metadata !"enzyme_dup", double %2, double %2)
ret double %4
}
여기서는 _zn2ad3_f217h3b3b1800bd39fde3e가 올바른 이름입니다. 앞에 붙은 @는 복사하지 않도록 주의하십시오. opt 명령을 다시 실행하여 2)단계를 반복하되, 이번에는 out.ll 대신 mwe.ll을 입력 파일로 전달하십시오. 이 최소화된 예제가 여전히 크래시를 재현하는지 확인하십시오.
4) (선택 사항) llvm-ir 재현 코드를 더 최소화하기
이전 단계를 마치면 약 5천 줄 분량의 mwe.ll 파일이 생겼을 것입니다. 이를 50줄까지 줄여 봅시다. opt 및 llvm-extract와 같은 위치에 있는 llvm-reduce 바이너리를 찾으십시오. 오류 메시지의 첫 줄을 복사하십시오. 예를 들면 다음과 같습니다:
opt: /home/manuel/prog/rust/src/llvm-project/llvm/lib/ir/instructions.cpp:686: void llvm::callinst::init(llvm::functiontype*, llvm::value*, llvm::arrayref<llvm::value*>, llvm::arrayref<llvm::operandbundledeft<llvm::value*> >, const llvm::twine&): assertion `(args.size() == fty->getnumparams() || (fty->isvararg() && args.size() > fty->getnumparams())) && "calling a function with bad signature!"' failed.
segfault만 발생한다면 의미 있는 오류 메시지가 없고 자동으로 할 수 있는 일도 많지 않으므로 5)단계로 넘어가십시오.
그렇지 않다면 다음 내용을 담은 script.sh 파일을 만드십시오
#!/bin/bash
<path/to/your/opt> $1 -load-pass-plugin=/path/to/llvmenzyme-19.so -passes="enzyme" \
|& grep "/some/path.cpp:686: void llvm::callinst::init"
grep에 어떤 오류 메시지를 전달할지 여러 가지로 시도해 보십시오. 오류를 고유하게 식별할 수 있을 만큼 충분히 길어야 합니다. 다만 ( 또는 )를 포함하는 긴 오류라면 이를 올바르게 이스케이프해야 하므로 번거로울 수 있습니다. 다음을 실행하십시오:
<path/to/llvm-reduce> --test=script.sh mwe.ll
input isn't interesting! verify interesting-ness test가 표시된다면 script.sh의 오류 메시지를 잘못 작성한 것이므로, grep이 실제 오류와 일치하는지 확인해야 합니다. 모든 것이 잘 진행되면 많은 반복이 수행된 후 새로운 reduced.ll 파일이 생성됩니다. opt로 여전히 같은 오류가 발생하는지 확인하십시오.
고급 디버깅: llvm-ir 수동 조사
최소화된 재현 코드(mwe.ll 또는 reduced.ll)를 얻었다면 더 깊이 파고들 수 있습니다:
- 수동 편집: llvm-ir을 직접 다시 작성해 보십시오. 간접 호출과 관련된 문제처럼 특정한 문제라면
__enzyme_virtualreverse와 같은 Enzyme 전용 인트린식을 조사해 볼 수 있습니다. 이를 사용하는 방법을 이해하려면 Enzyme의 문서나 소스 코드를 참고해야 할 수도 있습니다. - enzyme 테스트 케이스: 문제와 관련된 기능이나 인트린식의 올바른 사용법을 보여 줄 수 있는 테스트 케이스를 enzyme 저장소에서 찾아보십시오.
5) 버그를 보고하십시오.
그런 다음 mwe.ll(또는 reduced.ll) 예제를 복사하여 저희 컴파일러 탐색기에 붙여 넣을 수 있습니다.
- 언어로
llvm ir를, 컴파일러로opt 20을 선택하십시오. - 컴파일러 오른쪽 필드가 아직 설정되어 있지 않다면 그 값을
-passes="enzyme"로 바꾸십시오. - 이제는 익숙해진 오류가 다시 한번 나타날 것입니다.
- 공유 버튼을 사용하여 해당 링크를 복사하십시오.
- https://github.com/enzymead/enzyme/issues에 이슈를 생성하고
mwe.ll과 (있다면)reduced.ll, 그리고 컴파일러 탐색기 링크를 공유하십시오. Rust 코드나 그 코드의 링크도 자유롭게 추가하십시오.
조사 결과 문서화하기
"attempting to call an indirect active function whose runtime value is inactive"와 같은 일부 enzyme 오류는 그동안 혼란을 일으켜 왔습니다. 이러한 문제를 조사한다면, 완전한 해결책을 찾지 못하더라도 조사 결과를 문서화하는 것을 고려해 주십시오. 얻은 통찰이 Rust에서의 사용에 한정되지 않고 enzyme 전반에 해당한다면, 이를 메인 enzyme 문서에 기여하는 것이 대개 가장 좋은 첫걸음입니다. 관련 enzyme GitHub 이슈에 조사 결과를 언급하거나, 적절하다면 이 문서의 수정을 제안할 수도 있습니다. 이렇게 하면 다른 사람들이 처음부터 다시 시작하지 않아도 됩니다.
명확한 재현 예제와 문서가 있다면 enzyme 개발자가 버그를 고칠 수 있을 것입니다. 버그가 고쳐지면 Rust 컴파일러 내부의 enzyme 서브모듈이 업데이트되며, 이를 통해 Rust 코드를 미분할 수 있게 될 것입니다. rust-ad 개선에 도움을 주셔서 감사합니다.
Rust 코드 최소화하기
최소한의 llvm-ir 재현 예제뿐만 아니라, 의존성이 없는 최소한의 Rust 재현 예제가 있는 것도 도움이 됩니다. 이렇게 하면 버그를 고친 후 이를 CI 테스트 케이스로 추가할 수 있어 향후 회귀를 방지할 수 있습니다.
Rust 재현 예제를 최소화하는 데 도움이 되는 몇 가지 방법이 있습니다. 아마 가장 간단한 자동화 방법은 cargo-minimize입니다.
그 밖에도 treereduce, halfempty, picireny, 그리고 경우에 따라 creduce 등 다양한 대안이 있습니다.
지원되는 RUSTFLAGS
디버깅이나 프로파일링을 돕기 위해 실험적인 -Z autodiff rustc 플래그를 지원하도록 추가했습니다. 이 플래그는 RUSTFLAGS를 통해 cargo에 전달할 수 있으며, rustc를 다시 컴파일하지 않고도 Enzyme의 동작을 변경할 수 있게 해 줍니다. 현재 autodiff에 대해 다음 값을 지원합니다.
디버그 플래그
PrintTA // Print TypeAnalysis information
PrintTAFn // Print TypeAnalysis information for a specific function
PrintAA // Print ActivityAnalysis information
Print // Print differentiated functions while they are being generated and optimized
PrintPerf // Print AD related Performance warnings
PrintModBefore // Print the whole LLVM-IR module directly before running AD
PrintModAfter // Print the whole LLVM-IR module after running AD, before optimizations
PrintModFinal // Print the whole LLVM-IR module after running optimizations and AD
LooseTypes // Risk incorrect derivatives instead of aborting when missing Type Info
LooseTypes는 Can not deduce type of <X>라는 Enzyme 오류를 없애고 일부 코드를 실행할 수 있게 하는 데 유용한 경우가 많습니다. 하지만 이 플래그는 잘못된 그레이디언트를 만들어 낼 가능성이 분명히 있다는 점을 유의하십시오. 더 나쁜 것은, 그레이디언트가 특정 입력값에서는 올바르지만 다른 입력값에서는 올바르지 않을 수도 있다는 점입니다. 그러므로 이러한 버그는 이슈로 등록하시고, 이 플래그는 버그가 수정되기를 기다리는 동안에만 임시로 사용하십시오.
벤치마크 플래그
성능 실험과 벤치마킹을 위해 다음 값도 지원합니다.
NoPostopt // We won't optimize the LLVM-IR Module after AD
RuntimeActivity // Enables the runtime activity feature from Enzyme
Inline // Instructs Enzyme to maximize inlining as far as possible, beyond LLVM's default
쉼표를 구분자로 사용하여 여러 autodiff 값을 조합할 수 있습니다:
RUSTFLAGS="-Z autodiff=Enable,LooseTypes,PrintPerf" cargo +enzyme build
-Zautodiff=Enable을 사용하면 autodiff를 사용할 수 있게 되며, 일반적인 rustc 컴파일 파이프라인이 다음과 같이 바뀝니다:
- 선택한 컴파일 파이프라인을 실행합니다. 릴리스 빌드를 선택한 경우, 벡터화와 루프 언롤링을 비활성화합니다.
- 함수를 미분합니다.
- 선택한 컴파일 파이프라인을 모듈 전체에 대해 다시 실행합니다. 이번에는 벡터화나 루프 언롤링을 비활성화하지 않습니다.
Autodiff를 위한 TypeTree
TypeTree란 무엇입니까?
Enzyme을 위한 메모리 레이아웃 기술자입니다. 타입이 메모리에서 어떻게 구성되는지 Enzyme에 정확히 알려 주어 도함수를 효율적으로 계산할 수 있게 합니다.
구조
#![allow(unused)]
fn main() {
TypeTree(Vec<Type>)
Type {
offset: isize, // byte offset (-1 = everywhere)
size: usize, // size in bytes
kind: Kind, // Float, Integer, Pointer, etc.
child: TypeTree // nested structure
}
}
예시: fn compute(x: &f32, data: &[f32]) -> f32
입력 0: x: &f32
#![allow(unused)]
fn main() {
TypeTree(vec![Type {
offset: -1, size: 8, kind: Pointer,
child: TypeTree(vec![Type {
offset: 0, size: 4, kind: Float, // Single value: use offset 0
child: TypeTree::new()
}])
}])
}
입력 1: data: &[f32]
#![allow(unused)]
fn main() {
TypeTree(vec![Type {
offset: -1, size: 8, kind: Pointer,
child: TypeTree(vec![Type {
offset: -1, size: 4, kind: Float, // -1 = all elements
child: TypeTree::new()
}])
}])
}
출력: f32
#![allow(unused)]
fn main() {
TypeTree(vec![Type {
offset: 0, size: 4, kind: Float, // Single scalar: use offset 0
child: TypeTree::new()
}])
}
왜 필요합니까?
- Enzyme은 LLVM IR로부터 복잡한 타입 레이아웃을 추론할 수 없습니다
- 느린 메모리 패턴 분석을 피할 수 있습니다
- 중첩된 구조체에 대해 올바른 도함수 계산을 가능하게 합니다
- 어떤 바이트가 미분 가능한 값이고 어떤 바이트가 메타데이터인지 Enzyme에 알려 줍니다
Enzyme이 이 정보로 하는 일:
TypeTree가 없는 경우:
; Enzyme sees generic LLVM IR:
define float @distance(ptr %p1, ptr %p2) {
; Has to guess what these pointers point to
; Slow analysis of all memory operations
; May miss optimization opportunities
}
TypeTree가 있는 경우:
define "enzyme_type"="{[-1]:Float@float}" float @distance(
ptr "enzyme_type"="{[-1]:Pointer, [-1,0]:Float@float}" %p1,
ptr "enzyme_type"="{[-1]:Pointer, [-1,0]:Float@float}" %p2
) {
; Enzyme knows exact type layout
; Can generate efficient derivative code directly
}
TypeTree - 오프셋과 -1에 대한 설명
타입 구조
#![allow(unused)]
fn main() {
Type {
offset: isize, // WHERE this type starts
size: usize, // HOW BIG this type is
kind: Kind, // WHAT KIND of data (Float, Int, Pointer)
child: TypeTree // WHAT'S INSIDE (for pointers/containers)
}
}
오프셋 값
일반 오프셋(0, 4, 8 등)
구조체 내의 특정 바이트 위치
#![allow(unused)]
fn main() {
struct Point {
x: f32, // offset 0, size 4
y: f32, // offset 4, size 4
id: i32, // offset 8, size 4
}
}
&Point에 대한 TypeTree(내부 표현):
#![allow(unused)]
fn main() {
TypeTree(vec![
Type { offset: 0, size: 4, kind: Float }, // x at byte 0
Type { offset: 4, size: 4, kind: Float }, // y at byte 4
Type { offset: 8, size: 4, kind: Integer } // id at byte 8
])
}
생성되는 LLVM
"enzyme_type"="{[-1]:Pointer, [-1,0]:Float@float, [-1,4]:Float@float, [-1,8]:Integer, [-1,9]:Integer, [-1,10]:Integer, [-1,11]:Integer}"
오프셋 -1(특수 값: “모든 곳”)
“이 패턴이 모든 요소에 대해 반복됨“을 의미합니다
예시 1: 직접 배열 [f32; 100](포인터 간접 참조 없음)
#![allow(unused)]
fn main() {
TypeTree(vec![Type {
offset: -1, // ALL positions
size: 4, // each f32 is 4 bytes
kind: Float, // every element is float
}])
}
생성되는 LLVM: "enzyme_type"="{[-1]:Float@float}"
예시 1b: 배열 참조 &[f32; 100](포인터 간접 참조 있음)
#![allow(unused)]
fn main() {
TypeTree(vec![Type {
offset: -1, size: 8, kind: Pointer,
child: TypeTree(vec![Type {
offset: -1, // ALL array elements
size: 4, // each f32 is 4 bytes
kind: Float, // every element is float
}])
}])
}
생성되는 LLVM: "enzyme_type"="{[-1]:Pointer, [-1,-1]:Float@float}"
오프셋 0,4,8,12...396을 가진 100개의 개별 Type을 나열하는 대신
예시 2: 슬라이스 &[i32]
#![allow(unused)]
fn main() {
// Pointer to slice data
TypeTree(vec![Type {
offset: -1, size: 8, kind: Pointer,
child: TypeTree(vec![Type {
offset: -1, // ALL slice elements
size: 4, // each i32 is 4 bytes
kind: Integer
}])
}])
}
LLVM을 생성합니다: "enzyme_type"="{[-1]:Pointer, [-1,-1]:Integer}"
예제 3: 혼합 구조
#![allow(unused)]
fn main() {
struct Container {
header: i64, // offset 0
data: [f32; 1000], // offset 8, but elements use -1
}
}
#![allow(unused)]
fn main() {
TypeTree(vec![
Type { offset: 0, size: 8, kind: Integer }, // header
Type { offset: 8, size: 4000, kind: Pointer,
child: TypeTree(vec![Type {
offset: -1, size: 4, kind: Float // ALL array elements
}])
}
])
}
핵심 차이점: 단일 값과 배열
단일 값은 정밀도를 위해 오프셋 0을 사용합니다:
&f32는 오프셋 0에 정확히 하나의 f32 값을 가집니다- -1(“모든 위치”)을 사용하는 것보다 더 정밀합니다
- 생성 결과:
{[-1]:Pointer, [-1,0]:Float@float}
배열은 효율성을 위해 오프셋 -1을 사용합니다:
&[f32; 100]에는 같은 패턴이 100번 반복됩니다- -1을 사용하면 100개의 개별 오프셋을 나열하지 않아도 됩니다
- 생성 결과:
{[-1]:Pointer, [-1,-1]:Float@float}
소스 코드 표현
이 부분에서는 사용자에게서 받은 원시 소스 코드를 컴파일러가 쉽게 다룰 수 있는 여러 형태로 변환하는 과정을 설명합니다. 이러한 형태를 중간 표현(intermediate representations, IR)이라고 부릅니다.
이 과정은 컴파일러가 사용자가 요청한 것을 이해하는 데서 시작합니다. 즉, 주어진 명령줄 인자를 파싱하고 무엇을 컴파일해야 하는지 결정합니다. 그다음 컴파일러는 사용자 입력을 일련의 IR로 변환하며, 이 IR은 단계가 진행될수록 사용자가 작성한 코드와 점점 달라집니다.
구문과 AST
소스 코드를 직접 다루는 것은 매우 불편하고 오류가 발생하기 쉽습니다. 따라서 다른 어떤 작업보다 먼저, 원시 소스 코드를 추상 구문 트리(Abstract Syntax Tree, AST)로 변환합니다. 이 작업에는 렉싱, 파싱, 매크로 확장, 이름 해석, 조건부 컴파일, 기능 게이트 검사, AST 검증 등 상당히 많은 작업이 수반됩니다. 이 장에서는 이러한 단계들을 모두 살펴봅니다.
특히, 이러한 작업들 사이에 항상 명확한 순서가 있는 것은 아닙니다. 예를 들어, 매크로 확장은 매크로와 임포트의 이름을 해석하기 위해 이름 해석에 의존합니다. 또한 파싱에는 매크로 확장이 필요하며, 매크로 확장은 다시 매크로의 출력을 파싱해야 할 수도 있습니다.
렉싱과 파싱
컴파일러가 가장 먼저 하는 일은 (UTF-8 유니코드 텍스트로 된) 프로그램을 받아, 컴파일러가 문자열보다 더 편리하게 다룰 수 있는 데이터 형식으로 바꾸는 것입니다. 이 작업은 렉싱과 파싱의 두 단계로 이루어집니다.
- 렉싱은 문자열을 받아 토큰 스트림으로 바꿉니다. 예를 들어
foo.bar + buz는foo,.,bar,+,buz토큰으로 바뀝니다. 이는rustc_lexer에 구현되어 있습니다.
- 파싱은 토큰 스트림을 받아 컴파일러가 다루기 더 쉬운 구조화된 형태로 바꾸며, 이 형태를 보통 추상 구문 트리(AST)라고 부릅니다.
AST
AST는 Rust 프로그램의 구조를 메모리에 그대로 반영하며, Span을 사용하여 특정 AST 노드를 원래의 소스 텍스트와 연결합니다. AST는 rustc_ast에 정의되어 있으며, 토큰과 토큰 스트림에 대한 몇 가지 정의, AST를 변경하기 위한 자료 구조/트레이트, 그리고 컴파일러의 다른 AST 관련 부분(렉서와 매크로 확장 등)을 위한 공유 정의도 함께 들어 있습니다.
AST의 모든 노드는 고유한 NodeId를 가지며, 여기에는 구조체 같은 최상위 항목뿐만 아니라 개별 문과 표현식도 포함됩니다. NodeId는 크레이트 안에서 AST 노드를 고유하게 식별하는 식별자 번호입니다.
하지만 이 번호는 크레이트 안에서 절대적이기 때문에, AST에서 노드 하나만 추가하거나 제거해도 그 뒤에 오는 모든 NodeId가 바뀝니다. 그래서 변경되는 것을 가능한 한 적게 유지해야 하는 증분 컴파일에서는 NodeId가 사실상 쓸모가 없습니다.
NodeId는 매크로 확장이나 이름 해석처럼 AST를 직접 다루는 rustc의 모든 부분에서 사용됩니다(이에 대해서는 다음 몇 장에서 더 자세히 다룹니다).
파싱
파서는 rustc_parse에 정의되어 있으며, 여기에는 렉서에 대한 고수준 인터페이스와 매크로 확장 이후에 실행되는 몇 가지 검증 루틴도 함께 들어 있습니다. 특히 rustc_parse::parser에 파서 구현이 들어 있습니다.
파서의 주요 진입점은 rustc_parse에 있는 여러 parse_* 함수와 그 밖의 함수들입니다. 이 함수들을 사용하면 SourceFile(예: 파일 하나에 담긴 소스)을 토큰 스트림으로 바꾸고, 그 토큰 스트림으로 파서를 만든 다음, 파서를 실행하여 Crate(루트 AST 노드)를 얻는 등의 작업을 할 수 있습니다.
복사량을 최소화하기 위해 Lexer와 Parser는 모두 부모인 ParseSess에 자신을 묶는 라이프타임을 가집니다. 여기에는 파싱하는 동안 필요한 모든 정보뿐만 아니라 SourceMap 자체도 들어 있습니다.
파싱하는 동안 매크로 정의나 호출을 만날 수 있다는 점에 유의하십시오. 이것들은 나중에 확장하도록 따로 보관해 둡니다(매크로 확장을 참고하십시오). 확장 과정 자체에서 매크로의 출력을 파싱해야 할 수도 있으며, 그 결과 확장할 매크로가 더 드러날 수 있고, 이런 과정이 계속 이어질 수 있습니다.
어휘 분석 더 알아보기
어휘 분석 코드는 두 크레이트로 나뉘어 있습니다.
-
rustc_lexer크레이트는&str를 토큰을 이루는 조각들로 나누는 일을 담당합니다. 렉서는 흔히 생성된 유한 상태 기계로 구현하지만,rustc_lexer의 렉서는 손으로 직접 작성되었습니다. -
Lexer는rustc_lexer를rustc에 특화된 자료 구조와 통합합니다. 구체적으로는rustc_lexer가 반환한 토큰에Span정보를 추가하고 식별자를 인터닝합니다.
매크로 확장
Rust에는 매우 강력한 매크로 시스템이 있습니다. 이전 장에서는 파서가 (임시 플레이스홀더를 사용하여) 확장할 매크로를 어떻게 따로 남겨 두는지 살펴보았습니다. 이 장에서는 확장되지 않은 매크로가 없는 크레이트의 완전한 추상 구문 트리(AST)를 얻을 때까지(또는 컴파일 오류가 날 때까지) 그 매크로들을 반복적으로 확장하는 과정을 다룹니다.
먼저 매크로 출력을 확장하여 AST에 통합하는 알고리즘을 설명합니다. 다음으로 위생(hygiene) 데이터가 어떻게 수집되는지 살펴봅니다. 마지막으로 여러 종류의 매크로를 확장하는 구체적인 방법을 살펴봅니다.
아래에서 설명하는 알고리즘과 자료 구조의 상당수는 rustc_expand에 있으며, 기본 자료 구조는 rustc_expand::base에 있습니다.
또한 cfg와 cfg_attr는 다른 매크로와 달리 특별하게 취급되며, rustc_expand::config에서 처리됩니다.
확장과 AST 통합
우선 확장은 크레이트 수준에서 일어납니다. 크레이트의 원시 소스 코드가 주어지면 컴파일러는 모든 매크로가 확장되고 모든 모듈이 인라인되는 등의 처리를 거친 거대한 AST를 만들어 냅니다. 이 과정의 주된 진입점은 MacroExpander::fully_expand_fragment 메서드입니다. 몇 가지 예외를 제외하면 이 메서드는 크레이트 전체에 사용됩니다(엣지 케이스 확장 문제에 대한 자세한 논의는 아래의 “즉시 확장”을 참조하십시오).
큰 틀에서 보면 fully_expand_fragment는 반복적으로 동작합니다. 해석되지 않은 매크로 호출(즉, 아직 정의를 찾지 못한 매크로)의 큐를 유지합니다. 큐에서 매크로를 하나 골라 해석하고, 확장한 뒤, 다시 통합하는 작업을 반복해서 시도합니다. 한 번의 반복에서 아무런 진전을 이루지 못하면 이는 컴파일 오류를 뜻합니다. 알고리즘은 다음과 같습니다:
- 해석되지 않은 매크로들의
queue를 초기화합니다. queue가 빌 때까지(또는 진전이 없을 때까지 — 이는 오류입니다) 다음을 반복합니다:- 부분적으로 만들어진 크레이트에서 가능한 한 많은 임포트를 해석합니다.
- 부분적으로 만들어진 크레이트에서 가능한 한 많은 매크로
Invocation(fn형태, 속성, derive)을 수집하여 큐에 추가합니다. - 첫 번째 요소를 큐에서 꺼내 해석을 시도합니다.
- 해석되었다면:
TokenStream또는 AST를 받아 (매크로 종류에 따라)TokenStream또는AstFragment를 만들어 내는 매크로의 확장 함수를 실행합니다. (TokenStream은TokenTree의 모음이며, 각각은 토큰(구두점, 식별자, 리터럴)이거나 구분된 그룹(()/[]/{}안의 모든 것)입니다.)- 이 시점에서는 매크로 자체에 대한 모든 것을 알고 있으므로
set_expn_data를 호출하여 전역 데이터에 그 속성을 채워 넣을 수 있습니다. 이것이ExpnId와 연관된 위생 데이터입니다(아래의 위생을 참고하십시오).
- 이 시점에서는 매크로 자체에 대한 모든 것을 알고 있으므로
- 그 AST 조각을 현재 존재하지만 부분적으로만 만들어진 AST에 통합합니다. 본질적으로 이 단계에서 “토큰 같은 덩어리“가 사이드 테이블을 갖춘, 확정된 온전한 AST가 됩니다. 이 과정은 다음과 같이 진행됩니다:
- 매크로가 토큰을 만들어 내는 경우(예: 절차적 매크로) 이를 AST로 파싱하며, 이때 파싱 오류가 발생할 수 있습니다.
- 확장 중에
SyntaxContext(계층 2)를 생성합니다(아래의 위생을 참고하십시오). - 매크로에서 새로 확장된 모든 AST 조각에 대해 다음 두 패스가 차례로 수행됩니다:
NodeId는InvocationCollector가 할당합니다. 이 과정에서는 새 AST 조각에 있는 새로운 매크로 호출도 수집하여 큐에 추가합니다.DefCollector는 “Def 경로”를 생성하고, 해당하는DefId를 할당하며, 축소된 그래프도 빌드합니다(해석기의 관점에서 이름을 모듈에 넣습니다).
- 매크로 하나를 확장하고 그 출력을 통합한 후에는
fully_expand_fragment의 다음 반복으로 넘어갑니다.
- 해석되지 않은 경우:
- 매크로를 다시 큐에 넣습니다.
- 다음 반복으로 넘어갑니다…
오류 복구
한 번의 반복에서 아무런 진전이 없다면 컴파일 오류(예: 정의되지 않은 매크로)에 도달한 것입니다. 진단을 생성하기 위해 실패(즉, 해석되지 않은 매크로나 임포트)로부터 복구를 시도합니다. 실패 복구는 해석되지 않은 매크로를 ExprKind::Err로 확장하는 방식으로 이루어집니다. 이렇게 하면 첫 번째 오류 이후에도 컴파일을 계속할 수 있으므로 rustc가 원래의 실패 외에 더 많은 오류를 보고할 수 있습니다.
이름 해석
여기에 이름 해석이 관여한다는 점에 주목하십시오. 위 알고리즘에서는 임포트와 매크로 이름을 해석해야 합니다. 이 작업은 rustc_resolve::macros에서 수행됩니다. 이곳에서는 매크로 경로를 해석하고, 그 해석 결과를 검증하며, 다양한 오류(예: “찾을 수 없음”, “찾았지만 불안정함”, “x를 예상했으나 y를 찾음”)를 보고합니다. 그러나 다른 이름은 아직 해석하려 하지 않습니다. 다른 이름의 해석은 나중에 이루어지며, 이는 이름 해석 장에서 살펴보겠습니다.
즉시 확장
즉시 확장이란 매크로 호출 자체보다 매크로 호출의 인자를 먼저 확장하는 것을 의미합니다. 이는 리터럴을 기대하는 몇몇 특수한 내장 매크로에 대해서만 구현되어 있습니다. 이러한 매크로 중 일부는 인자를 먼저 확장하면 사용자 경험이 더 매끄러워집니다. 예를 들어, 다음을 살펴보십시오.
macro bar($i: ident) { $i }
macro foo($i: ident) { $i }
foo!(bar!(baz));
지연 확장은 foo!를 먼저 확장합니다. 즉시 확장은 bar!를 먼저 확장합니다.
즉시 확장은 Rust에서 일반적으로 사용할 수 있는 기능이 아닙니다. 즉시 확장을 더 일반적으로 구현하기는 어렵기 때문에, 사용자 경험을 위해 몇몇 특수한 내장 매크로에 대해서만 구현합니다. 내장 매크로는 표준 라이브러리 임포트 주입이나 테스트 하네스 생성과 같은 다른 초기 코드 생성 기능과 함께 rustc_builtin_macros에 구현되어 있습니다. rustc_expand::build에는 AST 조각을 만들기 위한 몇 가지 추가 헬퍼가 있습니다. 즉시 확장은 일반적으로 지연(일반) 확장이 수행하는 작업의 일부를 수행합니다. 즉시 확장은 (평소처럼 크레이트 전체가 아니라) 크레이트의 일부에 대해서만 fully_expand_fragment를 호출하는 방식으로 이루어집니다.
기타 자료 구조
다음은 확장과 통합에 관여하는 그 밖의 주목할 만한 자료 구조입니다.
ResolverExpand- 크레이트 의존성을 끊는 데 사용되는trait입니다. 이를 통해rustc_resolve를 비롯한 거의 모든 것이rustc_ast에 의존함에도 불구하고rustc_ast에서 리졸버 서비스를 사용할 수 있습니다.ExtCtxt/ExpansionData- 확장에 쓰이는 여러 중간 인프라 데이터를 담습니다.Annotatable- 속성의 대상이 될 수 있는 AST 조각입니다. 매크로로 생성할 수는 있지만 속성을 붙일 수 없는 타입과 패턴을 제외하면AstFragment와 거의 같습니다.MacResult- “다형적인” AST 조각으로,AstFragmentKind(즉 아이템, 표현식, 패턴 등)에 따라 서로 다른AstFragment로 바뀔 수 있습니다.
위생성과 계층 구조
C/C++ 전처리기 매크로를 써 본 적이 있다면, 성가시고 디버깅하기 어려운 함정이 있다는 것을 아실 것입니다! 예를 들어 다음 C 코드를 살펴보십시오.
#define DEFINE_FOO struct Bar {int x;}; struct Foo {Bar bar;};
// Then, somewhere else
struct Bar {
...
};
DEFINE_FOO
대부분의 사람들은 C를 이렇게 작성하지 않으며, 거기에는 그럴 만한 이유가 있습니다. 컴파일되지 않기 때문입니다. 매크로가 정의한 struct Bar의 이름이 코드에서 정의한 struct Bar와 충돌합니다. 다음 예제도 살펴보십시오.
#define DO_FOO(x) {\
int y = 0;\
foo(x, y);\
}
// Then elsewhere
int y = 22;
DO_FOO(y);
문제가 보이십니까? 우리는 foo(22, 0) 호출을 생성하려 했지만, 매크로가 자체적으로 y를 정의했기 때문에 foo(0, 0)이 생성되었습니다!
두 가지 모두 매크로 위생성 문제의 예입니다. 위생성은 매크로 안에서 정의된 이름을 어떻게 처리하는지와 관련이 있습니다. 특히 위생적인 매크로 시스템은 매크로 안에서 도입된 이름 때문에 발생하는 오류를 방지합니다. Rust 매크로는 위와 같은 종류의 버그를 작성할 수 없게 한다는 점에서 위생적입니다.
큰 틀에서 보면, Rust 컴파일러의 위생성은 이름이 도입되고 사용되는 문맥을 추적하는 방식으로 이루어집니다. 그런 다음 그 문맥을 바탕으로 이름의 모호성을 해소할 수 있습니다. 앞으로 매크로 시스템이 발전하면 매크로 작성자가 그 문맥을 더 세밀하게 제어하여 사용할 수 있게 될 것입니다. 예를 들어 매크로 작성자는 매크로가 호출된 문맥에 새 이름을 도입하고 싶을 수 있습니다. 또는 매크로 작성자가 매크로 안에서만 사용할 변수를 정의하는 경우도 있습니다(즉 그 변수는 매크로 밖에서 보이지 않아야 합니다).
문맥은 AST 노드에 붙어 있습니다. 매크로가 생성한 모든 AST 노드에는 문맥이 붙어 있습니다. 그 밖에도 일부 디슈가링된 구문처럼 문맥이 붙어 있는 다른 노드가 있을 수 있습니다(매크로 확장으로 생성되지 않은 노드는 아래에서 설명하듯이 “루트” 문맥만 가진 것으로 간주합니다). 컴파일러 전반에서 코드 위치를 가리킬 때는 rustc_span::Span을 사용합니다. 뒤에서 보겠지만, 이 구조체에도 위생성 정보가 붙어 있습니다.
매크로 호출과 정의는 중첩될 수 있으므로 노드의 구문 문맥은 계층 구조여야 합니다. 예를 들어 매크로를 확장했는데 생성된 출력에 또 다른 매크로 호출이나 정의가 있다면, 구문 문맥은 그 중첩을 반영해야 합니다.
그런데 실제로는 목적에 따라 추적해야 할 문맥의 종류가 몇 가지 있습니다. 따라서 확장 계층 구조는 하나가 아니라 세 가지이며, 이들이 함께 크레이트의 위생성 정보를 구성합니다.
이 계층 구조들은 모두 확장 체인의 개별 요소를 식별하기 위한 일종의 “매크로 ID“가 필요합니다. 이 ID가 바로 ExpnId입니다. 모든 매크로는 정수 ID를 받으며, 이 ID는 새 매크로 호출을 발견할 때마다 0부터 차례로 할당됩니다. 모든 계층 구조는 ExpnId::root에서 시작하며, 이것은 자기 자신의 부모입니다.
rustc_span::hygiene 크레이트에는 위생성(hygiene)과 관련된 모든 알고리즘(Resolver::resolve_crate_root에 있는 몇 가지 핵(hack)은 예외입니다)과, 전역 데이터에 보관되는 위생성 및 확장 관련 구조체가 들어 있습니다.
실제 계층 구조는 HygieneData에 저장됩니다. 이는 위생성 및 확장 정보를 담고 있는 전역 데이터로, 어떤 Ident에서든 별도의 컨텍스트 없이 접근할 수 있습니다.
확장 순서 계층 구조
첫 번째 계층 구조는 확장의 순서, 즉 어떤 매크로 호출이 다른 매크로의 출력 안에 있는 경우를 추적합니다.
여기서 계층 구조의 자식은 “가장 안쪽“의 토큰이 됩니다. ExpnData 구조체 자체는 전역 데이터를 통해 사용할 수 있는 매크로 정의와 매크로 호출 양쪽의 속성 중 일부를 담고 있습니다. ExpnData::parent는 이 계층 구조에서 자식에서 부모로 향하는 링크를 추적합니다.
예를 들면 다음과 같습니다.
macro_rules! foo { () => { println!(); } }
fn main() { foo!(); }
이 코드에서 최종적으로 생성되는 AST 노드는 root -> id(foo) -> id(println)이라는 계층 구조를 갖게 됩니다.
매크로 정의 계층 구조
두 번째 계층 구조는 매크로 정의의 순서, 즉 한 매크로를 확장할 때 그 출력에서 다른 매크로 정의가 드러나는 경우를 추적합니다. 이 계층 구조는 다른 두 계층 구조보다 약간 까다롭고 더 복잡합니다.
SyntaxContext는 이 계층 구조의 체인 전체를 ID로 나타냅니다. SyntaxContextData에는 주어진 SyntaxContext와 연관된 데이터가 들어 있으며, 대부분은 해당 체인을 여러 방식으로 필터링한 결과를 담는 캐시입니다. 여기서는 SyntaxContextData::parent가 자식에서 부모로 향하는 링크이고, SyntaxContextData::outer_expns가 체인의 개별 요소입니다. 컴파일러 코드에서 “체이닝 연산자“는 SyntaxContext::apply_mark입니다.
앞서 언급한 Span은 사실 코드 위치와 SyntaxContext를 간결하게 표현한 것에 불과합니다. 마찬가지로 Ident는 인터닝된 Symbol + Span(즉, 인터닝된 문자열 + 위생성 데이터)에 불과합니다.
내장 매크로의 경우 SyntaxContext::empty().apply_mark(expn_id)라는 컨텍스트를 사용하며, 이러한 매크로는 계층 구조의 루트에서 정의된 것으로 간주됩니다. 아직 크레이트 간 위생성을 구현하지 않았기 때문에 proc macro에 대해서도 같은 방식을 사용합니다.
토큰이 매크로에 의해 생성되기 전에 X라는 컨텍스트를 가졌다면, 매크로에 의해 생성된 후에는 X -> macro_id라는 컨텍스트를 갖습니다. 다음은 몇 가지 예시입니다.
예시 0:
macro m() { ident }
m!();
여기서 처음에 SyntaxContext::root 컨텍스트를 가졌던 ident는 m에 의해 생성된 후 ROOT -> id(m) 컨텍스트를 갖습니다.
예시 1:
macro m() { macro n() { ident } }
m!();
n!();
이 예시에서 ident는 처음에 ROOT 컨텍스트를 갖고, 첫 번째 확장 후에는 ROOT -> id(m), 그다음에는 ROOT -> id(m) -> id(n) 컨텍스트를 갖습니다.
예시 2:
이러한 체인은 마지막 요소만으로 완전히 결정되지 않는다는 점, 다시 말해 ExpnId가 SyntaxContext와 동형이 아니라는 점에 유의하십시오.
macro m($i: ident) { macro n() { ($i, bar) } }
m!(foo);
모든 확장이 끝난 후 foo는 ROOT -> id(n) 컨텍스트를, bar는 ROOT -> id(m) -> id(n) 컨텍스트를 갖습니다.
현재 매크로 정의를 추적하는 이 계층 구조에는 이른바 “컨텍스트 이식 핵(context transplantation hack)”이 적용되어 있습니다. 최신(즉, 실험적인) 매크로는 기존의 “예제 기반 매크로(Macros By Example, MBE)” 시스템보다 위생성이 더 강하며, 이로 인해 두 시스템 사이에 이상한 상호작용이 발생할 수 있습니다. 이 핵은 당분간 “그냥 동작하도록” 만들기 위한 것입니다.
호출 위치 계층 구조
세 번째이자 마지막 계층 구조는 매크로 호출의 위치를 추적합니다.
이 계층 구조에서 ExpnData::call_site는 child -> parent 링크입니다.
다음은 예시입니다.
macro bar($i: ident) { $i }
macro foo($i: ident) { $i }
foo!(bar!(baz));
최종 출력의 baz AST 노드에서 확장 순서 계층 구조는 ROOT -> id(foo) -> id(bar) -> baz이고, 호출 위치 계층 구조는 ROOT -> baz입니다.
매크로 백트레이스
매크로 백트레이스는 rustc_span::hygiene의 위생(hygiene) 메커니즘을 사용하여 rustc_span에 구현되어 있습니다.
매크로 출력 생성하기
앞에서 매크로의 출력이 크레이트의 AST에 어떻게 통합되는지 살펴보았고, 크레이트의 위생 데이터가 어떻게 생성되는지도 살펴보았습니다. 그렇다면 매크로의 출력은 실제로 어떻게 생성할까요? 이는 매크로의 종류에 따라 다릅니다.
Rust에는 두 종류의 매크로가 있습니다.
macro_rules!매크로(일명 “예시에 의한 매크로(Macros By Example)”(MBE)), 그리고- 사용자 정의 derive를 포함하는 절차적 매크로(proc macro)입니다.
파싱 단계에서 일반 Rust 파서는 매크로와 그 호출의 내용을 따로 보관해 둡니다. 이후 코드의 이러한 부분을 사용하여 매크로가 확장됩니다.
여기서 중요한 자료 구조/인터페이스는 다음과 같습니다.
SyntaxExtension- 낮춰진(lowered) 매크로 표현으로, 확장기 함수를 포함합니다. 확장기 함수는TokenStream이나 AST를 다른TokenStream이나 AST로 변환하며, 여기에 안정성이나 매크로 내부에서 허용되는 불안정 기능 목록과 같은 몇 가지 추가 데이터가 더해집니다.SyntaxExtensionKind- 확장기 함수는 여러 가지 다른 시그니처를 가질 수 있습니다(토큰 스트림 하나나 둘, 또는 AST 조각 등을 받습니다). 이는 그 시그니처들을 나열하는enum입니다.BangProcMacro/TTMacroExpander/AttrProcMacro/MultiItemModifier- 확장기 함수 시그니처를 나타내는trait들입니다.
예시에 의한 매크로
MBE에는 Rust 파서와 구별되는 자체 파서가 있습니다. 매크로가 확장될 때 매크로를 파싱하고 확장하기 위해 MBE 파서를 호출할 수 있습니다. MBE 파서는 다시 매크로 호출의 내용을 파싱하는 도중 메타변수(예: $my_expr)를 바인딩해야 할 때 Rust 파서를 호출할 수 있습니다. 매크로 확장 코드는 compiler/rustc_expand/src/mbe/에 있습니다.
예시
macro_rules! printer {
(print $mvar:ident) => {
println!("{}", $mvar);
};
(print twice $mvar:ident) => {
println!("{}", $mvar);
println!("{}", $mvar);
};
}
여기서 $mvar를 메타변수라고 부릅니다. 메타변수는 일반 변수와 달리 런타임에 값에 바인딩되지 않고, 컴파일 타임에 토큰 트리에 바인딩됩니다. 토큰은 식별자(예: foo)나 구두점(예: =>)처럼 문법을 이루는 하나의 “단위“입니다. EOF와 같은 특수 토큰도 있습니다. EOF는 더 이상 토큰이 없음을 나타냅니다. 짝을 이루는 괄호류 문자((…), […], {…})로 만들어지는 토큰 트리도 있습니다. 이러한 토큰 트리는 여는 문자와 닫는 문자, 그리고 그 사이의 모든 토큰을 포함합니다(Rust에서는 괄호류 문자의 짝이 반드시 맞아야 합니다). 매크로 확장은 소스 파일의 원시 바이트가 아니라 토큰 스트림을 대상으로 동작합니다. 그 덕분에 많은 복잡성이 추상화됩니다. 매크로 확장기(그리고 컴파일러의 나머지 대부분)는 코드에서 어떤 구문 요소가 놓인 정확한 줄과 열을 고려하지 않습니다. 대신 코드에 어떤 구문 요소가 쓰였는지를 고려합니다. 토큰을 사용하면 어디에 있는지 걱정하지 않고 무엇인지에만 신경 쓸 수 있습니다. 토큰에 대한 자세한 내용은 이 책의 파싱 장을 참고하십시오.
printer!(print foo); // `foo` is a variable
매크로 호출을 구문 트리 println!("{}", foo)로 확장한 다음, 이 구문 트리를 다시 Display::fmt 호출로 확장하는 과정은 매크로 확장의 대표적인 예입니다.
MBE 파서
매크로 파서가 수행하는 MBE 확장은 두 부분으로 이루어집니다.
- 정의를 파싱하는 것, 그리고
- 호출을 파싱하는 것입니다.
MBE 파서는 Earley 파싱 알고리즘과 원리가 비슷한 알고리즘을 사용하므로, 비결정적 유한 오토마톤(NFA) 기반의 정규식 파서로 볼 수 있습니다. 매크로 파서는 compiler/rustc_expand/src/mbe/macro_parser.rs에 정의되어 있습니다.
매크로 파서의 인터페이스는 다음과 같습니다(약간 단순화했습니다).
fn parse_tt(
&mut self,
parser: &mut Cow<'_, Parser<'_>>,
matcher: &[MatcherLoc]
) -> ParseResult
매크로 파서에서는 다음 항목을 사용합니다.
parser변수는 일반 Rust 파서의 상태를 가리키는 참조입니다. 이 상태에는 토큰 스트림과 파싱 세션이 포함됩니다. 토큰 스트림은 MBE 파서에게 파싱을 맡길 대상입니다. 원시 토큰 스트림을 소비하여, 메타변수와 그에 대응하는 토큰 트리 사이의 바인딩을 출력합니다. 파싱 세션은 파서 오류를 보고하는 데 사용할 수 있습니다.matcher변수는 토큰 스트림과 매칭하려는MatcherLoc의 시퀀스입니다. 이들은 매칭하기 전에 매크로 정의의 원래 토큰 트리로부터 변환됩니다.
정규식 파서에 비유하면 토큰 스트림은 입력이고, 이를 매처가 정의한 패턴과 대조하여 매칭합니다. 앞의 예시를 사용하면 토큰 스트림은 예시 호출 print foo의 내부를 담은 토큰 스트림이 될 수 있고, 매처는 토큰 (트리)의 시퀀스인 print $mvar:ident가 될 수 있습니다.
파서의 출력은 ParseResult입니다. 이 값은 다음 세 가지 경우 중 어느 것이 발생했는지를 나타냅니다.
- 성공: 토큰 스트림이 주어진 매처와 일치하며, 메타변수에서 대응하는 토큰 트리로의 바인딩이 생성되었습니다.
- 실패: 토큰 스트림이 매처와 일치하지 않으며, “No rule expected token …“과 같은 오류 메시지가 발생합니다.
- 오류: 파서에서 치명적인 오류가 발생했습니다. 예를 들어 패턴 매칭 결과가 둘 이상이면 이 경우가 발생합니다. 이는 매크로가 모호하다는 뜻이기 때문입니다.
전체 인터페이스는 여기에 정의되어 있습니다.
매크로 파서는 한 가지 예외를 제외하면 일반적인 정규식 파서와 거의 똑같이 동작합니다. ident, block, expr 등 다양한 종류의 메타변수를 파싱하려면 매크로 파서가 일반 Rust 파서를 다시 호출해야 합니다.
매크로 정의를 파싱하는 코드는 compiler/rustc_expand/src/mbe/macro_rules.rs에 있습니다. 매크로 파서의 구현에 대한 자세한 내용은 compiler/rustc_expand/src/mbe/macro_parser.rs의 주석을 참고하십시오.
앞의 예제로 설명하면, 호출에서 나온 토큰 스트림 print foo를 앞서 매크로 정의의 규칙에서 추출한 매처 print $mvar:ident 및 print twice $mvar:ident와 매칭해 봅니다. 매크로 파서는 현재 매처에서 비단말(예: $mvar:ident)을 매칭해야 하는 지점에 이르면, 일반 Rust 파서를 다시 호출하여 해당 비단말의 내용을 얻습니다. 이 경우 Rust 파서는 ident 토큰을 찾고, 찾아낸 토큰(foo)을 매크로 파서에 반환합니다. 그런 다음 매크로 파서가 파싱을 계속합니다.
여러 규칙의 매처 중 정확히 하나만 호출과 매칭되어야 한다는 점에 유의하십시오. 둘 이상이 매칭되면 파싱이 모호해지고, 하나도 매칭되지 않으면 구문 오류가 발생합니다.
정확히 하나의 규칙이 매칭된다고 가정하면, 매크로 확장은 좌변과 매칭할 때 포착한 값들로 치환하면서 해당 규칙의 우변을 전사합니다.
절차적 매크로
절차적 매크로 역시 파싱 중에 확장됩니다. 하지만 절차적 매크로는 컴파일러 안에 파서를 두는 대신 사용자 정의 서드파티 크레이트로 구현됩니다. 컴파일러는 절차적 매크로 크레이트와 그 안에서 특별한 어노테이션이 붙은 함수(즉, 절차적 매크로 자체)를 컴파일하고, 이 함수들에 토큰 스트림을 전달합니다. 그러면 절차적 매크로는 토큰 스트림을 변환하여 새 토큰 스트림을 출력할 수 있으며, 이 토큰 스트림은 AST로 합성됩니다.
절차적 매크로가 사용하는 토큰 스트림 타입은 안정화된 타입이므로 rustc는 내부적으로 이 타입을 사용하지 않습니다. 컴파일러의 (불안정한) 토큰 스트림은 rustc_ast::tokenstream::TokenStream에 정의되어 있습니다. 이 토큰 스트림은 rustc_expand::proc_macro와 rustc_expand::proc_macro_server에서 안정화된 proc_macro::TokenStream으로 변환되고 다시 역변환됩니다. Rust ABI는 현재 불안정하므로 이 변환에는 C ABI를 사용합니다.
사용자 정의 derive
사용자 정의 derive는 절차적 매크로의 특수한 한 종류입니다.
예제에 의한 매크로와 매크로 2.0
MBE 시스템에 위생성 관련 기능을 더하고 스코프 및 가시성 규칙을 개선하는 등 MBE 시스템을 개선하려는, 오래되었고 대부분 문서화되지 않은 작업이 있습니다. 내부적으로는 오늘날의 MBE와 같은 메커니즘에 몇 가지 문법적 설탕을 더해 사용하며, 네임스페이스 안에 둘 수 있습니다.
이름 해석
이전 장들에서는 모든 매크로가 확장된 상태로 추상 구문 트리(AST)가 어떻게 만들어지는지 살펴보았습니다. 또한 이를 위해 임포트와 매크로 이름을 해석하는 이름 해석 작업이 어느 정도 필요하다는 것도 살펴보았습니다. 이 장에서는 이것이 실제로 어떻게 이루어지는지와 그 밖의 내용을 설명합니다.
사실 매크로 확장 중에는 완전한 이름 해석을 하지 않습니다. 그 시점에는 임포트와 매크로만 해석합니다. 이는 무엇을 확장해야 하는지 알기 위해서라도 필요합니다. 이후 전체 AST가 갖춰지면, 크레이트의 모든 이름을 해석하기 위해 완전한 이름 해석을 수행합니다. 이 작업은 rustc_resolve::late에서 이루어집니다. 매크로 확장 중과 달리, 이 후기 확장에서는 새로운 이름이 추가될 수 없으므로 이름 해석을 한 번만 시도하면 됩니다. 이름 해석에 실패하면 컴파일러 오류가 됩니다.
이름 해석은 복잡합니다. 서로 다른 네임스페이스(예: 매크로, 값, 타입, 라이프타임)가 있으며, 이름은 서로 다른 (중첩된) 스코프에서 유효할 수 있습니다. 또한 이름의 종류에 따라 해석에 실패하는 방식이 다를 수 있고, 스코프에 따라 실패가 일어나는 방식도 다를 수 있습니다. 예를 들어 모듈 스코프에서 실패란 해당 모듈에 확장되지 않은 매크로도, 해석되지 않은 글롭 임포트도 없다는 것을 의미합니다. 반면 함수 본문 스코프에서 실패하려면 이름이 현재 블록, 모든 바깥 스코프, 그리고 전역 스코프 어디에도 없어야 합니다.
기본 사항
프로그램에서는 변수, 타입, 함수 등에 이름을 붙여서 이를 참조합니다. 이러한 이름이 항상 고유한 것은 아닙니다. 예를 들어 다음과 같은 유효한 Rust 프로그램을 봅시다.
#![allow(unused)]
fn main() {
type x = u32;
let x: x = 1;
let y: x = 2;
}
3번째 줄에서 x가 타입(u32)인지 값(1)인지 어떻게 알 수 있습니까? 이러한 충돌은 이름 해석 과정에서 해결됩니다. 이 경우 이름 해석은 타입 이름과 변수 이름이 별도의 네임스페이스에 존재하므로 공존할 수 있다고 정의합니다.
Rust의 이름 해석은 두 단계로 이루어진 과정입니다. 첫 번째 단계는 macro 확장 중에 실행되며, 이 단계에서 모듈 트리를 빌드하고 import를 해석합니다. 매크로 확장과 이름 해석은 ResolverAstLoweringExt 트레이트를 통해 서로 통신합니다.
두 번째 단계의 입력은 입력 파일을 파싱하고 macros를 확장하여 만들어진 구문 트리입니다. 이 단계에서는 소스에 있는 모든 이름에서 해당 이름이 도입된 관련 위치로 향하는 링크를 만듭니다. 또한 오타 수정 제안, 임포트할 트레이트, 사용되지 않는 항목에 대한 린트 등 유용한 오류 메시지도 생성합니다.
두 번째 단계(Resolver::resolve_crate)가 성공적으로 실행되면, 나머지 컴파일 과정에서 (hir::lowering::Resolver 인터페이스를 통해) 현재 존재하는 이름에 대해 질의할 때 사용할 수 있는 일종의 인덱스가 만들어집니다.
이름 해석은 rustc_resolve 크레이트에 있으며, 대부분은 lib.rs에 있고 일부 헬퍼나 심볼 종류별 로직은 다른 모듈에 있습니다.
네임스페이스
서로 다른 종류의 심볼은 서로 다른 네임스페이스에 존재합니다 ‒ 예를 들어 타입은 변수와 충돌하지 않습니다. 변수는 소문자로, 타입은 대문자로 시작하므로 보통은 이런 일이 일어나지 않지만, 이는 관례일 뿐입니다. 다음은 (경고와 함께) 컴파일되는 올바른 Rust 코드입니다:
#![allow(unused)]
fn main() {
type x = u32;
let x: x = 1;
let y: x = 2; // See? x is still a type here.
}
이 문제와 더불어 이러한 네임스페이스마다 조금씩 다른 스코프 규칙에 대처하기 위해, 해석기는 네임스페이스들을 분리해 두고 각각에 대해 별도의 구조를 빌드합니다.
다시 말해, 코드에서 네임스페이스를 언급할 때 이는 모듈 계층 구조가 아니라 타입, 값, 매크로의 구분을 뜻합니다.
스코프와 립(rib)
이름은 소스 코드의 특정 영역에서만 보입니다. 이는 계층 구조를 이루지만 반드시 단순한 구조인 것은 아닙니다 ‒ 한 스코프가 다른 스코프의 일부라고 해서, 바깥쪽 스코프에서 보이는 이름이 안쪽 스코프에서도 보이거나 같은 대상을 가리킨다는 뜻은 아닙니다.
이에 대처하기 위해 컴파일러는 Rib이라는 개념을 도입합니다. 이는 스코프를 추상화한 것입니다. 보이는 이름의 집합이 바뀔 가능성이 있을 때마다 새로운 Rib이 스택에 푸시됩니다. 이런 일이 일어날 수 있는 곳의 예는 다음과 같습니다:
- 명백한 곳 ‒ 블록을 감싸는 중괄호, 함수 경계, 모듈.
let바인딩 도입 ‒ 이는 같은 이름을 가진 다른 바인딩을 가릴 수 있습니다.- 매크로 확장 경계 ‒ 매크로 위생(hygiene)에 대처하기 위함입니다.
이름을 찾을 때는 ribs 스택을 가장 안쪽부터 바깥쪽으로 순회합니다. 이렇게 하면 이름의 가장 가까운 의미(다른 무엇에도 가려지지 않은 의미)를 찾을 수 있습니다. 바깥쪽 Rib으로 넘어가는 것은 어떤 이름을 사용할 수 있는지에도 영향을 줄 수 있습니다 ‒ 중첩된 함수(클로저가 아닌)가 있으면, 일반적인 스코프 규칙에 따르면 보여야 하는데도 안쪽 함수는 바깥쪽 함수의 매개변수와 지역 바인딩에 접근할 수 없습니다. 예시는 다음과 같습니다.
#![allow(unused)]
fn main() {
fn do_something<T: Default>(val: T) { // <- New rib in both types and values (1)
// `val` is accessible, as is the helper function
// `T` is accessible
let helper = || { // New rib on the block (2)
// `val` is accessible here
}; // End of (2), new rib on `helper` (3)
// `val` is accessible, `helper` variable shadows `helper` function
fn helper() { // <- New rib in both types and values (4)
// `val` is not accessible here, (4) is not transparent for locals
// `T` is not accessible here
} // End of (4)
let val = T::default(); // New rib (5)
// `val` is the variable, not the parameter here
} // End of (5), (3) and (1)
}
네임스페이스마다 규칙이 조금씩 다르기 때문에, 각 네임스페이스는 다른 네임스페이스와 병렬로 구성되는 독립적인 Rib 스택을 가집니다. 또한 지역 레이블(예: 루프나 블록의 이름)을 위한 Rib 스택도 있는데, 이는 그 자체로 완전한 네임스페이스는 아닙니다.
전체 전략
크레이트 전체의 이름 해석을 수행하기 위해, 구문 트리를 위에서 아래로 순회하며 만나는 모든 이름을 해석합니다. 이름이 사용되는 시점에는 이미 그 이름이 Rib 계층 구조에 도입되어 있으므로, 대부분의 이름에 대해서는 이 방식이 잘 동작합니다.
여기에는 몇 가지 예외가 있습니다. 아이템은 마주치기 전에도 사용될 수 있으므로 조금 까다롭습니다 ‒ 따라서 모든 블록은 먼저 아이템을 훑어서 해당 블록의 Rib을 채워야 합니다.
이보다 더 까다로운 것으로는 재귀적인 고정점(fixed-point) 해석이 필요한 임포트와, 나머지 코드를 처리하기 전에 해석하고 확장해야 하는 매크로가 있습니다.
따라서 이름 해석은 여러 단계에 걸쳐 수행됩니다.
추측적 크레이트 로딩
유용한 오류를 제공하기 위해, rustc는 경로를 찾을 수 없으면 해당 경로를 스코프로 가져오도록 제안합니다. 이것은 어떻게 이루어집니까? rustc는 모든 크레이트의 모든 모듈을 살펴보며 일치할 가능성이 있는 항목을 찾습니다. 여기에는 아직 로드되지 않은 크레이트까지 포함됩니다!
아직 로드되지 않은 크레이트를 import 제안에 포함하기 위해 크레이트를 미리 로드하는 것을 추측성 크레이트 로딩이라고 부릅니다. 이 과정에서 발생하는 오류는 보고되어서는 안 되기 때문입니다. 크레이트를 로드하기로 결정한 것은 사용자가 아니라 rustc_resolve입니다. 이 작업을 수행하는 함수는 lookup_import_candidates이며, rustc_resolve::diagnostics에 있습니다.
추측성 로드와 사용자가 시작한 로드를 구별하기 위해 rustc_resolve는 record_used 매개변수를 전달하며, 이 값은 로드가 추측성일 때 false입니다.
TODO: #16
이 내용은 코드를 처음 훑어보며 학습한 결과입니다. 분명히 불완전하며 충분히 상세하지 않습니다. 또한 일부 부정확한 부분이 있을 수 있습니다. 그럼에도 불구하고, 그 안에서 어떤 일이 일어나는지 파악하는 데 유용한 첫 이정표는 될 것입니다.
- 정확히 무엇과 연결되며, 그 결과는 이후의 컴파일 단계에 어떻게 공개되고 사용됩니까?
- 누가 이것을 호출하며, 실제로 어떻게 사용됩니까?
- 한 번의 패스로 실행된 뒤 그 결과만 사용됩니까, 아니면 증분적으로 계산될 수 있습니까?
- 전반적인 전략에 대한 설명이 다소 모호합니다.
Rib라는 이름은 어디에서 유래했습니까?- 이 부분에는 자체 테스트가 있습니까, 아니면 일부 e2e 테스트의 일부로만 테스트됩니까?
속성
속성에는 두 가지 유형이 있습니다. 비활성(inert) 속성(또는 내장(built-in) 속성)과 활성(active) 속성(비내장(non-builtin) 속성)입니다.
내장/비활성 속성
이 속성들은 컴파일러 자체, 즉 compiler/rustc_feature/src/builtin_attrs.rs와 속성 파서에 정의되어 있습니다.
예로는 #[allow]와 #[macro_use]가 있습니다.
이 속성들에는 몇 가지 중요한 특징이 있습니다.
- 이 속성들은 항상 스코프 안에 있으며, 일반적인 경로 기반 해석에 참여하지 않습니다.
- 이 속성들은 이름을 바꿀 수 없습니다. 예를 들어
use allow as foo는 컴파일되지만,#[foo]라고 작성하면 오류가 발생합니다. - 이 속성들은 ’비활성(inert)’입니다. 즉, 매크로 확장 코드가 이 속성들을 그대로 둡니다. 따라서 모든 동작은 컴파일러가 이 속성들의 존재 여부를 명시적으로 검사한 결과로 나타납니다. 예를 들어 린트 관련 코드는
#[allow],#[warn],#[deny],#[forbid]를 명시적으로 검사합니다. 동작이 속성 자체의 확장에서 비롯되는 것이 아닙니다.
이 속성들에 대한 자세한 내용은 속성 파싱에 관한 장을 참고하십시오.
새 내장 속성 추가에 관한 참고 사항
경고: 새 내장 속성을 추가하면 이름 해석 모호성이 생길 수 있습니다
새 내장 속성(이름이 예약되지 않은 것, 즉 이름이 rustc로 시작하지 않는 새 내장 속성)을 추가하면, 불안정(unstable) 기능 게이트를 적용하더라도 안정 코드에서 이름 해석 모호성 때문에 호환성이 깨질 수 있다는 점에 유의하십시오. 이는 (1) 안정 코드에 같은 이름의 매크로가 있고 그 매크로가 재익스포트되는 경우, 또는 (2) 같은 이름의 proc-macro derive 헬퍼 속성이 있는 경우에 발생합니다.
일반적으로 내장 속성은 사용자 정의 매크로나 proc-macro 헬퍼 속성과 충돌하지 않도록 처음에는 #[foo]가 아닌 #[rustc_foo]로 시작해야 할 것입니다. 그런 다음 안정화 전에 #[foo]로의 이름 변경을 별도로 수행해야 하며, 이때 crater 실행으로 영향을 평가하고 T-lang이 검토할 의도적 호환성 파괴 FCP 제안을 함께 제출해야 합니다.
또한 crater가 모든 경우를 다루지 않으며, 현존하는 모든 안정 코드를 포함하지도 않는다는 점을 기억하십시오.
참고:
- 내장 속성은 prelude 속성과 다르게 취급되며, 불안정 내장 속성은 안정 매크로와 이름이 충돌할 수 있고, 내장 속성은 하위 호환성을 깨뜨릴 수 있음 #134963 및 이 이슈의 백링크를 참고하십시오. 여기에는 이런 종류의 호환성 깨짐 위험을 해결하는 방법에 관한 설계 논의도 포함되어 있습니다.
- 업데이트 후 빌드 실패: coverage가 모호함, 내장 속성과의 이름 충돌로 인해 모호함 #121157.
- 회귀: align이 모호함 #143834.
‘비내장’/‘활성’ 속성
이 속성들은 크레이트에서 정의합니다. 이 크레이트는 표준 라이브러리일 수도 있고 proc-macro 크레이트일 수도 있습니다.
중요: #[derive] 같은 많은 비내장 속성도 여전히 Rust 핵심 언어의 일부로 간주됩니다. 하지만 표준 라이브러리에 대응하는 정의가 있으므로 ’내장 속성’이라고 부르지 않습니다.
비내장 속성은 두 가지 형태로 정의됩니다.
- proc-macro 속성: proc-macro 크레이트에서
#[proc_macro_attribute]가 붙은 함수로 정의됩니다. - AST 기반 속성: 표준 라이브러리에 정의됩니다. 이 속성들에는
library/core/src/macros/mod.rs같은 곳에 정의된 특별한 ‘스텁(stub)’ 매크로가 있습니다.
이러한 정의는 매크로가 일반적인 경로 기반 해석에 참여할 수 있도록 하기 위해 존재합니다. 즉, 다른 아이템 정의와 마찬가지로 임포트하거나, 다시 내보내거나, 이름을 바꿀 수 있습니다. 그러나 정의의 본문은 비어 있습니다. 대신 매크로에는 #[rustc_builtin_macro] 속성이 붙어 있으며, 이 속성은 컴파일러에게 rustc_builtin_macros에 있는 대응 함수를 실행하도록 지시합니다.
내장되지 않은 모든 속성은 다음과 같은 특징을 가집니다.
- 다른 모든 정의(예: 구조체)와 마찬가지로, 이러한 속성은 임포트를 통해 스코프로 가져와야 합니다. 많은 표준 라이브러리 속성이 프렐루드에 포함되어 있습니다. 이 때문에
#[derive]를 임포트 없이 작성해도 동작합니다. - 이러한 속성은 매크로 확장에 참여합니다. 매크로 구현은 속성 대상을 변경하지 않고 그대로 두거나, 대상을 수정하거나, 새로운 AST 노드를 생성하거나, 대상을 완전히 제거할 수 있습니다.
#[test] 속성
많은 Rust 프로그래머는 #[test]라는 내장 속성을 사용합니다. 다음과 같이 함수에 표시하고 몇 가지 단언(assert)을 넣기만 하면 됩니다:
#[test]
fn my_test() {
assert!(2+2 == 4);
}
이 프로그램을 rustc --test 또는 cargo test로 컴파일하면 이 함수와 다른 모든 테스트 함수를 실행할 수 있는 실행 파일이 만들어집니다. 이런 테스트 방식을 쓰면 테스트가 코드와 자연스럽게 함께 있을 수 있습니다. 심지어 비공개 모듈 안에도 테스트를 넣을 수 있습니다:
mod my_priv_mod {
fn my_priv_func() -> bool {}
#[test]
fn test_priv_func() {
assert!(my_priv_func());
}
}
따라서 비공개 항목을 외부 테스트 장치에 어떻게 노출할지 고민하지 않고도 쉽게 테스트할 수 있습니다. 이것이 Rust에서 테스트를 편하게 작성할 수 있는 핵심 이유입니다. 그러나 의미론적으로는 꽤 이상합니다. 테스트가 보이지 않는다면 main 함수는 어떻게 이 테스트들을 호출합니까? rustc --test는 정확히 무엇을 합니까?
#[test]는 컴파일러의 rustc_ast 안에서 구문 변환으로 구현됩니다. 본질적으로 이것은 크레이트를 3단계로 다시 작성하는 정교한 macro입니다:
1단계: 재내보내기
앞에서 말했듯이 테스트는 비공개 모듈 안에 있을 수 있으므로, 기존 코드를 깨뜨리지 않고 테스트를 main 함수에 노출할 방법이 필요합니다. 이를 위해 rustc_ast는 테스트를 재귀적으로 재내보내는 __test_reexports라는 로컬 모듈을 만듭니다. 이 확장을 거치면 위 예제는 다음과 같이 바뀝니다:
mod my_priv_mod {
fn my_priv_func() -> bool {}
pub fn test_priv_func() {
assert!(my_priv_func());
}
pub mod __test_reexports {
pub use super::test_priv_func;
}
}
이제 테스트에 my_priv_mod::__test_reexports::test_priv_func로 접근할 수 있습니다. 모듈 구조가 더 깊으면 __test_reexports가 테스트를 포함한 모듈을 재내보내므로, a::b::my_test에 있는 테스트는 a::__test_reexports::b::__test_reexports::my_test가 됩니다. 이 과정은 꽤 안전해 보이지만, __test_reexports 모듈이 이미 있다면 어떻게 됩니까? 답은 아무 일도 일어나지 않는다는 것입니다.
그 이유를 설명하려면 Rust의 추상 구문 트리가 식별자를 어떻게 표현하는지 이해해야 합니다. 모든 함수, 변수, 모듈 등의 이름은 문자열로 저장되지 않고, 본질적으로 식별자마다 부여된 ID 번호인 불투명한 Symbol로 저장됩니다. 컴파일러는 별도의 해시 테이블을 두어 필요할 때(예: 구문 오류를 출력할 때) Symbol의 사람이 읽을 수 있는 이름을 복원할 수 있게 합니다. 컴파일러는 __test_reexports 모듈을 생성할 때 그 식별자에 새 Symbol을 생성합니다. 따라서 컴파일러가 생성한 __test_reexports는 여러분이 직접 작성한 모듈과 이름은 같을 수 있어도 Symbol은 같지 않습니다. 이 기법은 코드 생성 중에 이름이 충돌하는 것을 막으며, Rust macro 위생성의 기반이 됩니다.
2단계: 하네스 생성
이제 크레이트 루트에서 테스트에 접근할 수 있으므로, 이 테스트들로 무언가를 해야 합니다. 이를 위해 rustc_ast는 다음과 같은 모듈을 생성합니다:
#[main]
pub fn main() {
extern crate test;
test::test_main_static(&[&path::to::test1, /*...*/]);
}
여기서 path::to::test1은 test::TestDescAndFn 타입의 상수입니다.
이 변환은 단순하지만, 테스트가 실제로 어떻게 실행되는지 잘 보여 줍니다. 테스트는 배열로 모인 뒤 test_main_static이라는 테스트 러너에 전달됩니다. TestDescAndFn이 정확히 무엇인지는 나중에 다시 다루겠습니다. 지금 중요한 점은 Rust core의 일부이면서 테스트용 런타임 전체를 구현하는 test라는 크레이트가 있다는 것입니다. test의 인터페이스는 안정화되지 않았으므로, 이 크레이트를 안정적으로 사용하는 유일한 방법은 #[test] 매크로를 쓰는 것입니다.
3단계: 테스트 객체 생성
이전에 Rust로 테스트를 작성해 본 적이 있다면 테스트 함수에서 사용할 수 있는 선택적 속성 중 일부에 익숙할 것입니다. 예를 들어, 테스트가 패닉을 일으킬 것으로 예상되는 경우 테스트에 #[should_panic] 어노테이션을 붙일 수 있습니다. 대략 다음과 같은 모습입니다.
#[test]
#[should_panic]
fn foo() {
panic!("intentional");
}
이는 테스트가 단순한 함수 그 이상이며 설정 정보도 함께 가지고 있다는 뜻입니다. test는 이 설정 데이터를 TestDesc라는 struct로 인코딩합니다. 크레이트의 각 테스트 함수에 대해 rustc_ast는 해당 함수의 속성을 파싱하고 TestDesc 인스턴스를 생성합니다. 그런 다음 TestDesc와 테스트 함수를 결합하여, 이름에서 짐작할 수 있듯이 TestDescAndFn이라는 struct를 만듭니다. test_main_static은 이 구조체를 대상으로 동작합니다. 특정 테스트에 대해 생성된 TestDescAndFn 인스턴스는 다음과 같습니다.
self::test::TestDescAndFn{
desc: self::test::TestDesc{
name: self::test::StaticTestName("foo"),
ignore: false,
should_panic: self::test::ShouldPanic::Yes,
allow_fail: false,
},
testfn: self::test::StaticTestFn(||
self::test::assert_test_result(::crate::__test_reexports::foo())),
}
이러한 테스트 객체의 배열이 만들어지면, 2단계에서 생성된 하네스를 통해 테스트 러너에 전달됩니다.
생성된 코드 살펴보기
nightly rustc에는 unpretty라는 불안정 플래그가 있으며, 이를 사용하여 macro 확장 이후의 모듈 소스를 출력할 수 있습니다.
$ rustc my_mod.rs -Z unpretty=hir
Rust에서의 패닉
1단계: panic! 매크로 호출
실제로 패닉 매크로는 두 개가 있습니다. 하나는 core에 정의되어 있고, 다른 하나는 std에 정의되어 있습니다. 이는 core의 코드에서도 패닉이 발생할 수 있기 때문입니다. core는 std보다 먼저 빌드되지만, 패닉이 core에서 발생하든 std에서 발생하든 런타임에는 동일한 메커니즘을 사용하도록 하고자 합니다.
panic!의 core 정의 (패닉)
core의 panic! 매크로는 결국 다음과 같은 호출을 수행합니다(library/core/src/panicking.rs에 있습니다).
#![allow(unused)]
fn main() {
// NOTE This function never crosses the FFI boundary; it's a Rust-to-Rust call
extern "Rust" {
#[lang = "panic_impl"]
fn panic_impl(pi: &PanicInfo<'_>) -> !;
}
let pi = PanicInfo::new(
&fmt,
Location::caller(),
/* can_unwind */ true,
/* force_no_backtrace */ false,
);
unsafe { panic_impl(&pi) }
}
이를 실제로 해석하는 과정은 여러 단계의 간접 참조를 거칩니다.
-
compiler/rustc_hir/src/weak_lang_items.rs에서panic_impl은rust_begin_unwind심볼과 함께 ’약한 언어 항목(weak lang item)’으로 선언됩니다. 이는rustc_hir_analysis/src/collect.rs에서 실제 심볼 이름을rust_begin_unwind로 설정하는 데 사용됩니다.panic_impl은extern "Rust"블록 안에 선언되어 있다는 점에 유의하십시오. 이는 core가rust_begin_unwind라는 외부 심볼(링크 시점에 해석됨)을 호출하려 한다는 뜻입니다. -
library/std/src/panicking.rs에는 다음과 같은 정의가 있습니다.
#![allow(unused)]
fn main() {
/// Entry point of panics from the core crate (`panic_impl` lang item).
#[cfg(not(any(test, doctest)))]
#[panic_handler]
pub fn panic_handler(info: &core::panic::PanicInfo<'_>) -> ! {
...
}
}
특수한 panic_handler 속성은 compiler/rustc_passes/src/lang_items.rs를 통해 해석됩니다. extract_ast 함수는 panic_handler 속성을 panic_impl 언어 항목으로 변환합니다.
이제 std에 대응하는 panic_handler 언어 항목이 있습니다. 이 함수는 core의 extern { fn panic_impl } 정의와 동일한 과정을 거쳐, 최종적으로 rust_begin_unwind라는 심볼 이름을 갖게 됩니다. 링크 시점에 core의 심볼 참조는 std의 정의(Rust 소스에서 panic_handler라는 이름의 함수)로 해석됩니다.
따라서 런타임에 제어 흐름은 core에서 std로 넘어갑니다. 이를 통해 core에서 발생한 패닉도 다른 패닉이 사용하는 것과 동일한 인프라(패닉 훅, 언와인딩 등)를 거치게 됩니다.
panic!의 std 구현 (패닉)
실제 패닉 관련 로직은 여기에서 시작됩니다. library/std/src/panicking.rs에서 제어는 panic_with_hook으로 넘어갑니다. 이 메서드는 전역 패닉 훅을 호출하고 이중 패닉을 검사하는 역할을 합니다. 마지막으로 패닉 런타임이 제공하는 __rust_start_panic을 호출합니다.
__rust_start_panic 호출은 매우 특이합니다. usize로 변환된 *mut &mut dyn PanicPayload가 전달됩니다. 이 타입을 하나씩 분석해 봅시다.
-
PanicPayload는 내부 트레이트입니다. 이 트레이트는PanicPayload(사용자가 제공한 페이로드 타입을 감싸는 래퍼)에 대해 구현되어 있으며,fn take_box(&mut self) -> *mut (dyn Any + Send)메서드를 가지고 있습니다. 이 메서드는 사용자가 제공한 페이로드(T: Any + Send)를 받아 박스에 담고, 그 박스를 원시 포인터로 변환합니다. -
__rust_start_panic을 호출할 때 우리는&mut dyn PanicPayload를 가지고 있습니다. 그러나 이것은 팻 포인터(usize크기의 두 배)입니다. 이를 FFI 경계를 넘어 패닉 런타임에 전달하기 위해, 이 가변 참조에 대한 가변 참조(&mut &mut dyn PanicPayload)를 얻고, 이를 원시 포인터(*mut &mut dyn PanicPayload)로 변환합니다. 바깥쪽 원시 포인터는Sized타입(가변 참조)을 가리키므로 씬 포인터입니다. 따라서 이 씬 포인터를usize로 변환할 수 있으며, 이는 FFI 경계를 넘어 전달하기에 적합합니다.
마지막으로, 이 usize를 인자로 __rust_start_panic을 호출합니다. 이제 패닉 런타임에 진입했습니다.
2단계: 패닉 런타임
Rust는 panic_abort와 panic_unwind라는 두 가지 패닉 런타임을 제공합니다. 사용자는 빌드 시점에 Cargo.toml을 통해 둘 중 하나를 선택합니다.
panic_abort는 매우 단순합니다. 예상하시는 대로 이 런타임의 __rust_start_panic 구현은 그냥 중단(abort)합니다.
panic_unwind는 더 흥미로운 경우입니다.
이 런타임의 __rust_start_panic 구현에서는 usize를 받아 다시 *mut &mut dyn PanicPayload로 변환하고, 이를 역참조한 다음, &mut dyn PanicPayload에 대해 take_box를 호출합니다. 이 시점에서 우리는 페이로드 자체에 대한 원시 포인터(*mut (dyn Send + Any)), 즉 panic!을 호출한 사용자가 제공한 실제 값에 대한 원시 포인터를 가지게 됩니다.
이 시점에서 플랫폼 독립적인 코드는 끝납니다. 이제 플랫폼별 언와인딩 로직(예: unwind)을 호출합니다. 이 코드는 스택을 언와인딩하고, 각 프레임과 연관된 ’랜딩 패드(landing pad)’를 실행하며(현재는 소멸자 실행), 제어를 catch_unwind 프레임으로 넘기는 역할을 합니다.
모든 패닉은 프로세스를 중단시키거나 어떤 catch_unwind 호출에 의해 잡힌다는 점에 유의하십시오. 특히 std의 런타임 서비스에서는 사용자가 제공한 main 함수 호출이 catch_unwind로 감싸여 있습니다.
AST 검증
AST 검증은 트리의 각 항목을 방문하여 간단한 검사를 수행하는 별도의 AST 패스입니다. 이 패스는 복잡한 분석, 타입 검사, 이름 해석을 전혀 수행하지 않습니다.
컴파일러는 검증을 수행하기 전에 먼저 매크로를 확장합니다. 그다음 이 패스는 각 AST 항목이 올바른 상태인지 확인하는 검증을 수행합니다. 그리고 이 패스가 끝나면 컴파일러는 크레이트 해석 패스를 실행합니다.
검증
검증은 AstValidator 타입에 정의되어 있으며, 이 타입은 rustc_ast_passes 크레이트에 있습니다. 이 타입은 특정 언어 규칙을 어겼을 때 오류를 내보내는 다양한 간단한 검사를 구현합니다.
또한 AstValidator는 AST 항목(함수, 트레이트, 열거형 등)을 방문하는 방법을 정의하는 Visitor 트레이트를 구현합니다.
방문자는 각 항목에 대해 특정한 검사를 수행합니다. 예를 들어 AstValidator는 함수 선언을 방문할 때 해당 함수가 다음 조건을 만족하는지 검사합니다.
- 매개변수가
u16::MAX개를 넘지 않습니다. - C 가변 인자가 선언의 마지막에 옵니다.
- 문서 주석이 함수 매개변수에 적용되지 않습니다.
- 그 밖의 검증도 수행합니다.
기능 게이트 검사
기능 게이트를 추가, 제거, 이름 변경 또는 안정화하는 절차는 기능 게이트를 참고하십시오.
기능 게이트는 nightly 전용 #![feature(...)] 옵트인 없이 불안정한 언어 기능과 라이브러리 기능을 사용하지 못하도록 막습니다. 이 장에서는 기능 게이팅의 구현, 즉 게이트가 어디에서 정의되고, 어떻게 활성화되며, 사용이 어떻게 검증되는지를 설명합니다.
기능 정의
모든 기능 게이트 정의는 rustc_feature 크레이트에 있습니다.
- 불안정 기능은
declare_features!매크로를 통해rustc_feature/src/unstable.rs에 선언됩니다. 이를 통해 기능이 이슈 번호 및 추적 메타데이터와 연결됩니다. - 채택된 기능(안정화된 기능)은
rustc_feature/src/accepted.rs에 나열되어 있습니다. - 제거된 기능(명시적으로 금지된 기능)은
rustc_feature/src/removed.rs에 나열되어 있습니다. - 게이트가 적용된 내장 속성과 cfg는
rustc_feature/src/builtin_attrs.rs에 선언됩니다.
rustc_feature::Features 타입은 크레이트의 활성 기능 집합을 나타냅니다. 컴파일 중에는 enabled, incomplete, internal 같은 헬퍼를 사용하여 상태를 검사합니다.
기능 수집
AST 검증이나 확장 전에 rustc는 크레이트 수준의 #![feature(...)] 속성을 수집하여 활성 Features 집합을 빌드합니다.
- 수집은
rustc_expand/src/config.rs의features에서 이루어집니다. - 각
#![feature]항목은unstable,accepted,removed테이블을 기준으로 분류됩니다.- 제거된 기능은 즉시 오류를 발생시킵니다.
- 수락된(Accepted) 기능은 기록되지만 nightly를 요구하지 않습니다. stable/베타에서는
rustc_ast_passes/src/feature_gate.rs의maybe_stage_features가 nightly가 아님을 알리는 진단을 내보내고 안정화된 기능을 나열하며, “이미 안정화됨” 메시지가 바로 여기에서 나옵니다. - 불안정 기능은 활성화된 것으로 기록됩니다.
- 알 수 없는 기능은 라이브러리 기능으로 취급되어 나중에 검증됩니다.
-Z allow-features=...를 사용하면 허용 목록에 없는 불안정 기능이나 알 수 없는 기능은 모두 거부됩니다.RUSTC_BOOTSTRAP은UnstableFeatures::from_environment에 입력됩니다. 이 변수는 컴파일러를 “nightly“로 취급할지 여부를 제어하여, 부트스트래핑 중에 기능 게이트를 우회하거나 명시적으로 비활성화(-1)할 수 있게 합니다.
파서 게이팅
일부 구문은 파싱 중에 감지되어 게이트가 적용됩니다. 파서는 진단을 일관되게 유지하고 파싱이 끝날 때까지 미루기 위해, 나중에 검사할 스팬을 기록합니다.
rustc_session/src/parse.rs는GatedSpans와gate메서드를 정의합니다.- 파서는 게이트가 필요한 구문(예:
async for,yield, 실험적 패턴)을 만나면rustc_parse/src/parser/*에서 이를 사용합니다.
검사 패스
핵심 로직은 rustc_ast_passes/src/feature_gate.rs에 있으며, 주로 check_crate와 그 AST 방문자에 있습니다.
check_crate
check_crate는 상위 수준의 검증을 수행합니다.
maybe_stage_features: stable/베타에서#![feature]를 거부합니다.check_incompatible_features: 서로 호환되지 않는 기능 조합(rustc_feature::INCOMPATIBLE_FEATURES에 선언됨)이 함께 사용되지 않도록 보장합니다.check_new_solver_banned_features: 차세대 트레이트 솔버용 컴파일러 모드와 호환되지 않는 기능을 금지합니다.check_features_requiring_new_solver: 기존 솔버와 호환되지 않는 기능에는 새 트레이트 솔버를 요구합니다.- 파서가 게이트한 스팬: 파싱 중에 기록된
GatedSpans를 처리합니다(GatedSpans검사 참조).
GatedSpans 검사
check_crate는 sess.psess.gated_spans를 순회합니다.
gate_all!매크로는 기능이 활성화되지 않은 경우 게이트된 각 스팬에 대해 진단을 내보냅니다.- 일부 게이트에는 추가 로직이 있습니다(예:
yield는coroutines또는gen_blocks에 의해 허용될 수 있습니다). - 레거시 게이트(예:
box_patterns,try_blocks)는 하드 오류 대신 향후 비호환성 경고를 내보내는 별도의 경로를 사용할 수 있습니다.
AST 방문자
PostExpansionVisitor는 확장된 AST를 순회하며 확장 이후에 검증하기 더 쉬운 구문 요소를 검사합니다.
- 방문자는 헬퍼 매크로(
gate!,gate_alt!,gate_multi!)를 사용하여 다음을 검사합니다.- 기능이 활성화되어 있습니까?
- (내부 컴파일러 매크로의 경우)
span.allows_unstable이 이를 허용합니까?
- 예로는
trait_alias,decl_macro,extern types, 그리고 다양한impl Trait형태가 있습니다.
속성과 cfg
rustc는 구문 외에도 속성과 cfg 옵션을 게이트합니다.
내장 속성
rustc_ast_passes::check_attribute는BUILTIN_ATTRIBUTE_SET을 기준으로 속성을 검사합니다.- 속성이
AttributeGate::Gated이고 해당 기능이 활성화되지 않은 경우feature_err를 내보냅니다.
cfg 옵션
rustc_attr_parsing/src/attributes/cfg.rs는gate_cfg를 정의하고,rustc_feature::find_gated_cfg를 사용하여 게이트된cfg를 거부합니다.gate_cfg는Span::allows_unstable을 존중하므로,#[allow_internal_unstable]로 표시된 내부 컴파일러 매크로는cfg게이트를 우회할 수 있습니다.- 게이트된 cfg 목록은
rustc_feature/src/builtin_attrs.rs에 정의되어 있습니다.
진단
진단 헬퍼는 rustc_session/src/parse.rs에 있습니다.
feature_err와feature_warn은 표준화된 진단을 내보내며, 가능한 경우 추적 이슈 번호를 첨부합니다.rustc_span/src/lib.rs의Span::allows_unstable은 스팬이#[allow_internal_unstable]로 표시된 매크로에서 비롯되었는지 검사합니다. 이를 통해 내부 매크로는 stable 채널에서도 불안정 기능을 사용할 수 있으며, 사용자 코드에는 게이트가 그대로 적용됩니다.
언어 항목
컴파일러에는 플러그인 방식으로 끼워 넣을 수 있는 연산이 몇 가지 있습니다. 즉, 언어에 하드코딩되어 있지 않고 라이브러리에 구현되어 있으며, 특별한 표식으로 컴파일러에 그 존재를 알리는 기능입니다. 이 표식은 #[lang = "..."] 속성이며, ...에는 여러 가지 값, 즉 여러 가지 ’언어 항목’이 들어갈 수 있습니다.
이러한 언어 항목 중 상당수는 add(trait core::ops::Add)나 future_trait(trait core::future::Future)처럼 합리적인 구현 방식이 하나뿐입니다. 다른 언어 항목은 특정한 목적을 달성하기 위해 재정의할 수 있습니다. 예를 들어 바이너리의 진입점을 제어할 수 있습니다.
언어 항목이 제공하는 기능은 다음과 같습니다:
- 트레이트를 통한 오버로드 가능한 연산자:
==,<, 역참조(*),+등의 연산자에 대응하는 트레이트는 모두 언어 항목으로 표시되어 있으며, 앞의 네 가지는 각각eq,ord,deref,add입니다. - 패닉과 스택 언와인딩:
eh_personality,panic,panic_bounds_checks언어 항목. - 컴파일러가 사용하는 타입의 속성을 나타내는 데 쓰이는
std::marker의 트레이트:send,sync,copy언어 항목입니다. core::marker에 있는, 변성(variance)을 나타내는 데 쓰이는 특수 마커 타입:phantom_data언어 항목입니다.
언어 항목은 컴파일러가 지연 로드합니다. 예를 들어 Box를 전혀 사용하지 않는다면 exchange_malloc과 box_free 함수를 정의할 필요가 없습니다. 어떤 항목이 필요한데 현재 크레이트나 그 크레이트가 의존하는 어떤 크레이트에서도 찾을 수 없으면 rustc가 오류를 냅니다.
대부분의 언어 항목은 core 라이브러리에 정의되어 있지만, #![no_std]로 실행 파일을 빌드하려는 경우에는 보통 std가 제공하는 몇 가지 언어 항목을 직접 정의해야 합니다.
언어 항목 가져오기
tcx.lang_items()를 호출하여 언어 항목을 가져올 수 있습니다.
다음은 trait Sized {} 언어 항목을 가져오는 간단한 예제입니다:
#![allow(unused)]
fn main() {
// Note that in case of `#![no_core]`, the trait is not available.
if let Some(sized_trait_def_id) = tcx.lang_items().sized_trait() {
// do something with `sized_trait_def_id`
}
}
sized_trait()는 DefId 자체가 아니라 Option을 반환한다는 점에 유의하십시오. 언어 항목은 표준 라이브러리에 정의되어 있으므로, 누군가 #로 컴파일하면 해당 언어 항목이 존재하지 않을 수 있기 때문입니다. 다음 중 하나를 선택할 수 있습니다:
- 계속 진행하는 데 해당 언어 항목이 반드시 필요하다면 하드 오류를 내십시오(사용자 코드에서도 발생할 수 있는 상황이므로 패닉하지 마십시오).
DefId로 하려던 작업을 생략하고 제한된 기능으로 계속 진행하십시오.
전체 언어 항목 목록
언어 항목은 다음 위치에서 찾을 수 있습니다:
- 컴파일러 문서의 전체 레퍼런스:
rustc_hir::LangItem - ripgrep으로 생성하는, 소스 위치가 포함된 자동 목록:
rg '#\[.*lang =' library/
언어 항목은 명시적으로 불안정한 기능이며 새 릴리스마다 바뀔 수 있다는 점에 유의하십시오.
HIR
HIR(“High-Level Intermediate Representation”, 고수준 중간 표현)은 rustc 대부분에서 사용되는 주요 IR입니다. 이는 파싱, 매크로 확장, 이름 해석 이후에 생성되는 추상 구문 트리(AST)를 컴파일러가 다루기 쉬운 형태로 표현한 것입니다(HIR이 생성되는 방식은 로워링을 참고하십시오). HIR의 많은 부분은 Rust 표면 구문과 상당히 비슷합니다. 다만 Rust의 일부 표현식 형태는 디슈가링(desugaring)되어 사라집니다. 예를 들어 for 루프는 loop로 변환되므로 HIR에 나타나지 않습니다. 이 때문에 HIR은 일반적인 AST보다 분석하기에 더 적합합니다.
이 장에서는 HIR의 주요 개념을 다룹니다.
rustc에 -Z unpretty=hir-tree 플래그를 전달하면 코드의 HIR 표현을 볼 수 있습니다:
cargo rustc -- -Z unpretty=hir-tree
-Z unpretty=hir 옵션을 사용하면 원래 소스 코드 표현식에 더 가까운 HIR을 생성할 수도 있습니다:
cargo rustc -- -Z unpretty=hir
대역 외 저장소(out-of-band storage)와 Crate 타입
HIR의 최상위 자료 구조는 Crate이며, 현재 컴파일 중인 크레이트의 내용을 저장합니다(HIR은 항상 현재 크레이트에 대해서만 구성합니다). AST의 크레이트 자료 구조는 기본적으로 루트 모듈만 담고 있지만, HIR의 Crate 구조체는 크레이트의 내용을 정리하여 더 쉽게 접근할 수 있도록 하는 여러 맵과 그 밖의 요소를 담고 있습니다.
예를 들어 HIR에서는 개별 아이템(예: 모듈, 함수, 트레이트, impl 등)의 내용에 부모에서 바로 접근할 수 없습니다. 예를 들어 함수 bar()를 포함하는 모듈 아이템 foo가 있다고 합시다:
#![allow(unused)]
fn main() {
mod foo {
fn bar() { }
}
}
그러면 HIR에서 모듈 foo의 표현(Mod 구조체)은 bar()의 ItemId I만 가지게 됩니다. 함수 bar()의 세부 정보를 얻으려면 items 맵에서 I를 조회합니다.
이 표현 방식의 좋은 점 하나는 HIR 전체를 샅샅이 뒤질 필요 없이 이 맵들의 키-값 쌍을 순회하는 것만으로 크레이트의 모든 아이템을 순회할 수 있다는 것입니다. 트레이트 아이템과 impl 아이템, 그리고 “바디(body)”(아래에서 설명합니다)에 대해서도 비슷한 맵이 있습니다.
표현을 이렇게 구성하는 또 다른 이유는 증분 컴파일과 더 잘 통합하기 위해서입니다. 이렇게 하면 &rustc_hir::Item(예: 모듈 foo에 대한 것)에 접근하더라도 함수 bar()의 내용에는 바로 접근할 수 없습니다. 대신 bar()의 id에만 접근할 수 있으며, bar()의 내용을 조회하려면 그 id를 넘겨 어떤 함수를 호출해야 합니다. 이를 통해 컴파일러는 여러분이 bar()의 데이터에 접근했다는 사실을 관찰하고 그 의존성을 기록할 수 있습니다.
HIR의 식별자
HIR은 함께 존재하면서 각기 다른 목적을 수행하는 여러 종류의 식별자를 사용합니다.
-
DefId는 이름에서 알 수 있듯이 주어진 크레이트 안의 특정 정의, 즉 최상위 아이템을 식별합니다. 이는 두 부분으로 구성됩니다. 하나는 정의가 유래한 크레이트를 식별하는CrateNum이고, 다른 하나는 크레이트 내에서 정의를 식별하는DefIndex입니다.HirId와 달리DefId는 모든 표현식마다 있는 것이 아니므로 여러 컴파일에 걸쳐 더 안정적입니다. -
LocalDefId는 기본적으로 현재 크레이트에서 유래한 것으로 알려진DefId입니다. 따라서CrateNum부분을 생략할 수 있으며, 로컬 정의를 기대하는 함수에 로컬 정의만 전달되도록 타입 시스템으로 보장할 수 있습니다. -
HirId는 현재 크레이트의 HIR에 있는 노드를 고유하게 식별합니다. 이는 두 부분으로 구성됩니다. 하나는owner이고, 다른 하나는owner내에서 고유한local_id입니다. 이 조합을 사용하면 값이 더 안정적이 되며, 이는 증분 컴파일에 도움이 됩니다.DefId와 달리HirId는 표현식과 같은 세분화된 엔티티를 가리킬 수 있지만, 현재 크레이트 안에서만 유효합니다. -
BodyId는 현재 크레이트의 HIRBody를 식별합니다. 현재는HirId를 감싸는 래퍼에 불과합니다. HIR 본문에 대한 자세한 내용은 HIR 장을 참고하십시오.
이러한 식별자들은 TyCtxt를 통해 서로 변환할 수 있습니다.
HIR 연산
HIR을 다룰 때는 대부분 TyCtxt를 통해 작업하게 됩니다. 여기에는 대부분 hir_ 접두사가 붙은 여러 메서드가 hir::map 모듈에 정의되어 있으며, 이 메서드들은 다양한 종류의 ID를 서로 변환하고 HIR 노드와 연관된 데이터를 조회하는 데 사용됩니다.
예를 들어 LocalDefId가 있고 이를 HirId로 변환하려면 tcx.local_def_id_to_hir_id(def_id)를 사용하면 됩니다. 로컬 아이템만 HIR 노드를 가지므로 DefId가 아니라 LocalDefId가 필요합니다.
마찬가지로 tcx.hir_node(n)를 사용하면 HirId에 해당하는 노드를 조회할 수 있습니다. 이 메서드는 Option<Node<'hir>>를 반환하며, 여기서 Node는 맵에 정의된 열거형입니다. 이 값을 패턴 매칭하면 HirId가 어떤 종류의 노드를 가리켰는지 알 수 있고, 데이터 자체에 대한 포인터도 얻을 수 있습니다. n이 어떤 종류의 노드인지 미리 아는 경우도 많습니다. 예를 들어 n이 반드시 어떤 HIR 표현식이라는 것을 안다면 tcx.hir_expect_expr(n)를 사용할 수 있습니다. 이 메서드는 &hir::Expr를 추출하여 반환하며, n이 실제로 표현식이 아니면 패닉을 일으킵니다.
마지막으로 tcx.parent_hir_node(n)와 같은 호출로 노드의 부모를 찾을 수 있습니다.
HIR 본문
rustc_hir::Body는 함수나 클로저의 본문, 상수의 정의처럼 실행 가능한 코드를 나타냅니다. 본문은 소유자와 연관됩니다. 소유자는 보통 아이템(예: fn(), const)이지만, 클로저 표현식(예: |x, y| x + y)일 수도 있습니다. TyCtxt를 사용하면 주어진 def-id에 연관된 본문을 찾거나(hir_maybe_body_owned_by) 본문의 소유자를 찾을 수 있습니다(hir_body_owner_def_id).
AST 로워링
AST 로워링 단계는 AST를 HIR로 변환합니다. 즉, 타입 분석이나 이와 유사한 구문 독립적 분석과 관련이 없는 많은 구조가 제거됩니다. 이러한 구조의 예는 다음과 같으며, 이에 국한되지 않습니다.
- 괄호
- 대체 없이 제거되며, 트리 구조가 순서를 명시적으로 나타냅니다.
for루프match+loop+match로 변환됩니다.
- 전칭
impl Trait- 제네릭 인자로 변환됩니다(단, 사용자가 직접 작성하지 않았음을 알 수 있도록 몇 가지 플래그가 붙습니다).
- 존재
impl Trait- 가상의
existential type선언으로 변환됩니다.
- 가상의
AST 로워링의 구현은 rustc_ast_lowering 크레이트에 있습니다. 진입점은 lower_to_hir이며, 이 함수는 TyCtxt에서 확장 이후의 AST와 리졸버 데이터를 가져와 크레이트 전체에 대한 hir::Crate를 빌드합니다.
로워링은 HIR 소유자를 중심으로 구성됩니다. lower_to_hir가 먼저 크레이트를 인덱싱한 다음, ItemLowerer::lower_node가 각 크레이트, 항목, 연관 항목, 외부 항목을 로워링합니다.
로워링 로직의 대부분은 LoweringContext에 있습니다. 구현은 rustc_ast_lowering 크레이트 안의 item.rs, expr.rs, pat.rs, path.rs 등 여러 파일로 나뉘어 있지만, 모두 동일한 LoweringContext 상태와 ID 로워링 메커니즘을 공유합니다.
각 소유자는 자신만의 with_hir_id_owner 스코프 안에서 로워링됩니다. 이 때문에 아래의 HirId 불변 조건이 중요합니다. lower_node_id는 AST NodeId를 현재 소유자에 매핑하고, next_id는 디슈가링 과정에서 도입되는 HIR 전용 노드를 새로 만듭니다.
로워링은 compiler/rustc_passes/src/hir_id_validator.rs의 정합성 검사에 걸리지 않도록 몇 가지 불변 조건을 지켜야 합니다.
HirId를 생성했다면 반드시 사용해야 합니다. 따라서lower_node_id를 사용했다면, 그 결과로 나온NodeId나HirId를 반드시 사용해야 합니다(HIR에 있는 모든NodeId는 대응하는HirId가 존재하는지 검사되므로 둘 중 어느 것을 사용해도 됩니다).HirId의 로워링은 그것을 소유하는 아이템의 스코프 안에서 이루어져야 합니다. 즉, 현재 로워링 중인 아이템이 아닌 다른 아이템의 일부를 생성하는 경우에는with_hir_id_owner를 사용해야 합니다. 예를 들어 존재impl Trait를 로워링할 때 이런 상황이 발생합니다.- HIR 구조에 들어갈
NodeId는 그HirId가 사용되지 않더라도 반드시 로워링해야 합니다.let _ = self.lower_node_id(node_id);를 호출하는 것은 전적으로 올바른 방법입니다. AST에 존재하지 않던 새 노드를 생성하는 경우에는 그 노드들을 위한 새 ID를 반드시 생성해야 합니다. 이는next_id메서드를 호출하여 수행하며, 이 메서드는 새NodeId를 생성하는 동시에 자동으로 로워링까지 해 주므로HirId도 함께 얻을 수 있습니다.
새 DefId를 생성하는 경우, 각 DefId에는 대응하는 NodeId가 있어야 하므로 로워링 중에 새 NodeId를 생성하지 않아도 되도록 이러한 NodeId를 AST에 추가해 두는 것이 좋습니다. 이렇게 하면 어떤 대상의 NodeId를 통해 그 DefId를 찾을 수 있는 방법이 생긴다는 이점이 있습니다. 로워링에서 이 DefId가 여러 곳에 필요한 경우, 그 모든 곳에서 새 NodeId를 생성할 수는 없습니다. 그렇게 하면 새로운 DefId도 함께 생기기 때문입니다. AST에서 가져온 NodeId를 사용하면 이 문제가 발생하지 않습니다.
또한 NodeId가 있으면 로워링이 그때그때 DefId를 생성하는 대신 DefCollector가 DefId를 생성할 수 있습니다. DefId 생성을 한곳에 모으면 리팩터링하기도 쉽고 코드를 이해하기도 쉬워집니다.
속성 파싱
속성에는 비활성(또는 내장) 속성과 활성(비내장) 속성의 두 가지 유형이 있습니다. 비활성 속성은 HIR로 가는 AST 로워링 중에 파싱되고, 활성 속성은 확장 중에 확장됩니다. 둘의 차이에 대한 자세한 내용은 속성에 관한 페이지를 참조하십시오.
AST 로워링 중에 비활성 속성은 파싱되지 않은 TokenStream에서 파싱된 표현으로 변환됩니다. 파싱된 형태는 rustc_attr_ir 크레이트에 정의되어 있고, 파서는 rustc_attr_parsing 크레이트에 정의되어 있습니다.
새 비활성 속성 파서를 추가하는 단계별 안내
- BUILTIN_ATTRIBUTES에 속성 이름을 추가하십시오. 이 목록은 비활성 속성의 집합을 정의합니다.
- 속성 정의의
AttributeKind에 배리언트를 추가하십시오. 이 배리언트는 속성 파서가 생성할 속성의 파싱된 형태를 정의합니다. rustc_attr_ir/encode_cross_crate.rs의 match에 추가한 배리언트를 넣으십시오. 이 match는 해당 속성이 의존하는 크레이트에서 보여야 하는지 여부를 반환해야 합니다. 일반적으로 코드 생성 관련 속성은No이고, 분석 관련 속성은Yes입니다.rustc_attr_parsing/attributes/*.rs에 속성 파서의 상태를 보관할 새 구조체를 만드십시오. 대부분의 파서에서는 빈 구조체가 됩니다.- 이 구조체에 대해 다음 속성 파싱 트레이트 중 하나를 구현하십시오.
NoArgsAttributeParser는 항목당 한 번만 나타날 수 있고 인자를 받지 않는 속성에 사용합니다.#[no_mangle]이 그 예입니다.SingleAttributeParser는 항목당 한 번만 나타날 수 있고 인자를 받을 수 있는 속성에 사용합니다.#[inline(...)]이 그 예입니다.CombineAttributeParser는 항목당 여러 번 나타날 수 있고, 각각의 사용이 하나의 파싱된 속성으로 결합되는 속성에 사용합니다.#[feature(...)]이 그 예입니다.AttributeParser는 위의 트레이트 중 어느 것에도 깔끔하게 들어맞지 않는 속성에 사용합니다. 예를 들어 서로 다른 여러 속성(#[stable]과#[unstable]등)을 하나의 파싱된 속성으로 결합해야 하는 경우입니다,
- 선택한 속성 파서 트레이트의 연관 상수를 구현하십시오. 이 상수들이 모든 트레이트에 적용되는 것은 아닙니다.
PATH: 추가되는 속성의 이름입니다TEMPLATE: 속성의 구조를 설명하는 것으로, 진단을 안내하는 데에만 사용됩니다. 이 타입을 생성하려면 template! 매크로를 사용하십시오.STABILITY: 속성이 안정(게이트 없음)인지 불안정(기능 게이트 적용)인지를 나타냅니다.ALLOWED_TARGETS: 속성이 나타날 수 있는 대상입니다. 새 속성에서는 잘못된 대상에 모두 오류가 발생해야 하므로, 새 속성에는AllowedTargets::AllowList를 사용하고 대상을 허용할 때는Policy::Allow만 사용하십시오.ON_DUPLICATE: 속성이 잘못해서 여러 번 나타날 때의 동작을 제어합니다. 새 속성에는 보통OnDuplicate::Error를 사용해야 하며, 이것이 기본값입니다.SAFETY:#[unsafe(no_mangle)]처럼 속성을 사용할 때 unsafe로 표시해야 하는지를 제어합니다. 보통AttributeSafety::Normal이어야 하며, 이것이 기본값입니다.
- 관련 파싱 함수를 구현하십시오. 일반적으로 속성이 사용되는 컨텍스트를 제공하는
AcceptContext와, 속성에 전달된 인자를 담은ArgParser가 주어집니다. 인자가 잘못되었다면cx.adcx().expected_*계열 함수를 사용해 오류를 내보낸 다음return None을 해야 합니다.cx.expect_*계열 함수를 사용하면 이 두 단계를 하나로 합칠 수 있는 경우도 있으며, 이 함수들에는?연산자를 사용해 조기 반환할 수 있습니다. 속성 파서는 파싱된 속성을 반환하기 전에 속성이 유효한지 확인하기 위해 가능한 모든 검사를 수행해야 합니다. - 속성 파서를 속성 파서 목록에 추가하여 등록하십시오.
- 정보가 충분하지 않아 속성 파서에서 모든 검증을 수행할 수 없었다면,
rustc_passes/check_attr.rs에 추가 검증 단계를 넣으십시오. 이 코드는 HIR이 완전히 구축된 직후에 실행되므로, 분석에 전체 HIR을 사용할 수 있습니다.
속성 파서 사용하기
속성 파서를 사용하는 방법에는 두 가지가 있으며, 각각 Early 속성 파싱과 Late 속성 파싱이라고 부릅니다.
Late (일반적인 방법)
HIR이 구축된 후에 파싱된 표현을 사용하려면 여기에 정의된 find_attr! 매크로를 사용하십시오. 이 매크로는 다음과 같은 방식으로 사용할 수 있습니다.
find_attr!(tcx, <def_id>, Variant(...))는 def id에서 속성을 찾습니다.find_attr!(tcx, <hir_id>, Variant(...))는 HIR id에서 속성을 찾습니다.find_attr!(tcx, crate, Variant(...))는 현재 크레이트에서 속성을 찾습니다.find_attr!(attrs, Variant(...))는&[rustc_attr_ir::Attribute]타입인 attrs에서 속성을 찾습니다.
Variant는 AttributeKind 변형 중 하나와 패턴 매칭되는 패턴이며, 다음 형태 중 하나를 취할 수 있습니다:
find_attr!(..., Variant)는 속성이 존재하는지를 나타내는 불리언 값을 반환합니다.find_attr!(..., Variant(a, b, _) => (a, b))는 패턴으로 바인딩된 필드로 구성한,=>뒤의 값을 반환합니다.
Early (“limited“라고도 부릅니다)
HIR이 구축되기 전에 파싱된 표현을 사용하려면 보통 AttributeParser::parse_limited 함수를 사용해야 합니다. 이 함수는 &[ast::Attribute]의 슬라이스와 관심 있는 속성의 이름을 받아, 해당 속성이 존재하면 파싱합니다.
limited 파싱 중에는 어떤 진단도 내보내지 않습니다. 린트는 전혀 내보내지 않으며, 오류는 지연된 버그(delayed bug)로 내보냅니다. 다시 말해, parse_limited로 파싱된 속성은 이후 오류를 실제로 내보내는 AST 로워링 단계에서 다시 파싱될 것으로 예상합니다.
HIR 디버깅
-Z unpretty=hir 플래그를 사용하면 사람이 읽기 쉬운 형태의 HIR 표현을 생성할 수 있습니다. cargo 프로젝트에서는 cargo rustc -- -Z unpretty=hir로 이를 수행할 수 있습니다. 이 출력은 AST 로워링(lowering) 과정에서 코드가 어떻게 디슈가링(desugaring)되고 변환되었는지 한눈에 확인해야 할 때 유용합니다.
HIR에 있는 데이터 전체를 Debug 형식으로 덤프하려면 -Z unpretty=hir-tree 플래그를 사용하십시오. 이는 컴파일러의 관점에서 HIR의 전체 구조를 확인해야 할 때 유용할 수 있습니다.
NodeId나 DefId를 소스 코드와 대응시키려는 경우에는 -Z unpretty=expanded,identified 플래그가 유용할 수 있습니다.
TODO: 더 추가할 내용이 있습니까? #1159
모호한/모호하지 않은 타입과 상수
AST/HIR에서 타입 인자와 상수 인자는 모호한(ambig) 위치와 모호하지 않은(unambig) 위치라는 두 종류의 위치에 올 수 있습니다. 모호한 위치는 타입과 상수 중 어느 쪽으로 파싱해도 유효한 위치입니다. 모호하지 않은 위치는 한 종류로만 파싱하는 것이 유효한 위치입니다.
#![allow(unused)]
fn main() {
fn func<T, const N: usize>(arg: T) {
// ^ Unambig type position
let a: _ = arg;
// ^ Unambig type position
func::<T, N>(arg);
// ^ ^
// ^^^^ Ambig position
let _: [u8; 10];
// ^^ ^^ Unambig const position
// ^^ Unambig type position
}
}
모호한 위치에 있는 대부분의 타입/상수는 파싱 중에 타입인지 상수인지 구분할 수 있습니다. 이에 대한 예외는 경로와 추론된 제네릭 인자뿐입니다.
경로
#![allow(unused)]
fn main() {
struct Foo<const N: usize>;
fn foo<const N: usize>(_: Foo<N>) {}
}
파싱 시점에는 중괄호로 감싸지 않은 모든 제네릭 인자를 타입으로 파싱합니다(즉, 이들은 ast::GenericArg::Ty가 됩니다). 위 예제에서 이는 Foo에 대한 제네릭 인자를 ast::Ty::Path(N)를 감싸는 ast::GenericArg::Ty로 파싱한다는 뜻입니다.
그다음 이름 해석 중에는 다음과 같이 처리합니다:
- 제네릭 인자 위치에서 제네릭 인자가 없는 단일 세그먼트 경로를 만나면, 먼저 타입 네임스페이스에서 해석을 시도하고, 실패하면 값 네임스페이스에서 해석을 시도합니다.
- 그 밖의 모든 종류의 경로는 타입 네임스페이스에서만 해석을 시도합니다.
이것이 구현된 위치는 LateResolutionVisitor::visit_generic_arg를 참고하십시오.
마지막으로 AST 로워링 중에 타입 인자를 로워링하려 할 때, 먼저 그것이 Ty::Path인지, 그리고 값 네임스페이스의 무언가로 해석되었는지 확인합니다. 그렇다면 익명 상수를 만들고 타입 인자 대신 상수 인자로 로워링합니다.
이것이 구현된 위치는 LoweringContext::lower_generic_arg를 참고하십시오.
경로에 대한 모호성은 HIR로 전파되지 않는다는 점에 유의하십시오. HIR ty 로워링 중에 Ty 또는 Const로 변환되는 hir::GenericArg::Path 같은 것은 존재하지 않습니다(그렇게 구현할 수도 있기는 합니다).
추론된 인자(_)
#![allow(unused)]
fn main() {
struct Foo<const N: usize>;
fn foo() {
let _unused: Foo<_>;
}
}
로워링 이후에도 모호하게 남는 제네릭 인자는 경로 세그먼트 안의 추론된 제네릭 인자(_)뿐입니다. 위 예제에서 파싱 시점에는 Foo에 대한 _ 인자가 추론된 타입 인자인지 추론된 상수 인자인지 분명하지 않습니다.
모호한 AST 위치에서 추론된 인자는 ast::Ty::Infer를 감싸는 ast::GenericArg::Ty로 파싱됩니다. 그다음 AST 로워링 중에 ast::GenericArg::Ty를 로워링할 때, 그것이 추론된 타입인지 확인하고, 그렇다면 hir::GenericArg::Infer로 로워링합니다.
모호하지 않은 AST 위치에서 추론된 인자는 ast::Ty::Infer 또는 ast::AnonConst로 파싱됩니다. AnonConst의 경우는 꽤 특이합니다. “익명 상수“의 “본문“을 나타내기 위해 ast::ExprKind::Underscore를 사용하지만, 실제로는 HIR에서 이를 익명 상수로 로워링하지 않습니다.
AST가 ast::GenericArg::Infer를 갖도록 리팩터링하여 AnonConst의 이러한 중복된 의미와 모호한 위치에서의 ast::Ty::Infer 재사용을 없앨 수 있는지 검토해 볼 가치가 있습니다.
모호하지 않은 AST 위치에서는 AST 로워링 중에 추론된 인자를 타입 위치인지 상수 위치인지에 따라 각각 hir::TyKind::Infer 또는 hir::ConstArgKind::Infer로 로워링합니다. 모호한 AST 위치에서는 AST 로워링 중에 추론된 인자를 hir::GenericArg::Infer로 로워링합니다. 이것이 구현된 위치는 LoweringContext::lower_generic_arg를 참고하십시오.
이를 단순하게 구현하면, HIR의 구조만 보고 추론된 타입/상수가 HIR에 있을 수 있다고 생각할 만한 위치가 최대 5곳이 될 수 있습니다:
- 모호하지 않은 타입 위치에서
TyKind::Infer로 - 모호하지 않은 상수 인자 위치에서
ConstArgKind::Infer로 - 모호한 위치에서
GenericArg::Type(TyKind::Infer)로 - 모호한 위치에서
GenericArg::Const(ConstArgKind::Infer)로 표현되는 경우 - 모호한 위치에서
GenericArg::Infer로 표현되는 경우
제네릭 인자 위치에서는 항상 GenericArg::Infer로 하강(lower)하므로 3번과 4번 위치는 실제로 마주칠 수 없다는 점에 유의하십시오.
이 방식에는 몇 가지 실패 유형이 있습니다:
GenericArg::Infer는 검사하지만hir::TyKind/ConstArgKind::Infer검사를 잊은 방문자(visitor)를 작성하여, 의도치 않게 모호한 위치의 추론만 처리하게 될 수 있습니다.TyKind/ConstArgKind::Infer는 검사하지만GenericArg::Infer검사를 잊은 방문자를 작성하여, 의도치 않게 모호하지 않은 위치의 추론만 처리하게 될 수 있습니다.- 모호한 위치의 추론된 타입/상수를
GenericArg::Type/Const로 표현하는 일이 결코 없다는 사실을 모른 채,GenericArg::Type/Const(TyKind/ConstArgKind::Infer)와GenericArg::Infer를 검사하는 방문자를 작성할 수 있습니다. - 추론된 상수 인자도 존재한다는 사실을 잊고
ConstArgKind::Infer는 검사하지 않은 채 오직TyKind::Infer만 검사하는 방문자를 작성할 수 있습니다(그 반대의 경우도 마찬가지입니다).
추론된 타입/상수를 다룰 때 HIR 방문자 작성 과정에서 오류가 덜 발생하도록, 비교적 복잡한 시스템을 갖추고 있습니다:
-
컴파일러에는 타입이나 상수가 모호하지 않은 위치에 있을 때와 모호한 위치에 있을 때를 위한 서로 다른 타입인
Ty<AmbigArg>와Ty<()>가 있습니다.AmbigArg는 거주 불가능한(uninhabited) 타입으로, 모호한 위치에 있을 때TyKind와ConstArgKind의Infer변형(variant)을 선택적으로 “비활성화“하는 데 사용합니다. -
HIR 방문자의
visit_ty및visit_const_arg메서드는 타입/상수의 모호한 위치 버전만 받습니다. 모호하지 않은 타입/상수는 방문 과정에서 암묵적으로 모호한 타입/상수로 변환되며,Infer변형은 전용visit_infer메서드에서 처리됩니다.
이 방식에는 여러 가지 이점이 있습니다:
GenericArg::Type/Const가 추론된 타입/상수 인자를 표현할 수 없다는 점이 명확합니다.visit_ty와visit_const_arg의 구현자는 추론된 타입/상수를 결코 마주치지 않으므로, 올바르게 동작하는 것처럼 보이지만 엣지 케이스를 잘못 처리하는 방문자를 작성하는 것이 불가능합니다.visit_infer메서드가 HIR에 있는 추론된 타입/상수의 모든 경우를 처리하므로, 방문자는 하나의 전용 위치에서 추론된 타입/상수를 쉽게 처리할 수 있고 경우를 빠뜨리지 않게 됩니다.
THIR
THIR(“Typed High-Level Intermediate Representation”, 타입이 지정된 고수준 중간 표현)는 이전에 “High-Level Abstract IR“의 약자인 HAIR로 불렸으며, 타입 검사 이후에 생성되는, rustc가 사용하는 또 다른 IR입니다. 이것은 ( 2024년 1월 기준) MIR 구성, 완전성 검사, unsafe 검사에 사용됩니다.
이름에서 짐작할 수 있듯이, THIR는 모든 타입이 채워진 HIR의 하위 변환(lowered) 버전이며, 이는 타입 검사가 완료된 후에 가능합니다. 하지만 THIR에는 HIR과 구별되는 몇 가지 흥미로운 기능이 더 있습니다:
-
MIR와 마찬가지로 THIR는 본문, 즉 “실행 가능한 코드“만을 표현합니다. 여기에는 함수 본문뿐만 아니라, 예를 들어
const초기화 식도 포함됩니다. 구체적으로는 모든 본문 소유자에 대해 THIR가 생성됩니다. 따라서 THIR에는struct나trait같은 아이템에 대한 표현이 없습니다. -
THIR의 각 본문은 컴파일 과정이 끝날 때까지 저장되는 것(HIR는 이렇게 저장됩니다)과 달리, 일시적으로만 저장되며 더 이상 필요하지 않게 되는 즉시 해제됩니다.
-
THIR는 모든 노드의 타입을 제공할 뿐만 아니라, HIR에 비해 추가적인 디슈가링(desugaring)도 거칩니다. 예를 들어 자동 참조와 역참조가 명시적으로 표현되며, 메서드 호출과 오버로드된 연산자는 일반 함수 호출로 변환됩니다. 소멸 스코프(destruction scope) 또한 명시적으로 표현됩니다.
-
문(statement), 표현식, match 갈래, 블록, 매개변수는 각각 따로 저장됩니다. 예를 들어
stmts배열에 있는 문은exprs배열 내 표현식을 그 인덱스(ExprId로 표현됨)로 참조합니다.
THIR는 rustc_mir_build::thir에 있습니다. thir::Expr를 구성하려면 THIR가 할당될 메모리 아레나를 전달하여 thir_body 함수를 사용하면 됩니다. 이 아레나를 해제하면 THIR도 소멸되며, 이는 최대 메모리 사용량을 억제하는 데 유용합니다. 크레이트의 모든 본문에 대한 THIR 표현을 동시에 메모리에 두는 것은 매우 부담이 클 것입니다.
rustc에 -Zunpretty=thir-flat 플래그를 전달하면 THIR의 디버그 표현을 얻을 수 있습니다.
예시로 다음 코드를 사용해 봅시다:
fn main() {
let x = 1 + 2;
}
THIR에서 이것이 표현되는 방식은 다음과 같습니다( 2026년 7월 기준):
#![allow(unused)]
fn main() {
DefId(0:3 ~ main[26fd]::main):
Thir {
body_type: Fn(
fn(),
),
attributes: {},
// no match arms
arms: [],
blocks: [
Block {
targeted_by_break: false,
region_scope: Node(1),
span: main.rs:1:11: 3:2 (#0),
stmts: [
s0,
],
expr: None,
safety_mode: Safe,
},
],
exprs: [
// expression 0, a literal with a value of 1
Expr {
kind: Literal {
lit: Spanned {
node: Int(
Pu128(
1,
),
Unsuffixed,
),
span: main.rs:2:13: 2:14 (#0),
},
neg: false,
},
ty: i32,
temp_scope_id: 4,
span: main.rs:2:13: 2:14 (#0),
},
// expression 1, scope surrounding literal 1
Expr {
kind: Scope {
region_scope: Node(4),
hir_id: HirId(DefId(0:3 ~ main[26fd]::main).4),
// reference to expression 0 above
value: e0,
},
ty: i32,
temp_scope_id: 4,
span: main.rs:2:13: 2:14 (#0),
},
// expression 2, literal 2
Expr {
kind: Literal {
lit: Spanned {
node: Int(
Pu128(
2,
),
Unsuffixed,
),
span: main.rs:2:17: 2:18 (#0),
},
neg: false,
},
ty: i32,
temp_scope_id: 5,
span: main.rs:2:17: 2:18 (#0),
},
// expression 3, scope surrounding literal 2
Expr {
kind: Scope {
region_scope: Node(5),
hir_id: HirId(DefId(0:3 ~ main[26fd]::main).5),
// reference to expression 2 above
value: e2,
},
ty: i32,
temp_scope_id: 5,
span: main.rs:2:17: 2:18 (#0),
},
// expression 4, represents 1 + 2
Expr {
kind: Binary {
op: Add,
// references to scopes surrounding literals above
lhs: e1,
rhs: e3,
},
ty: i32,
temp_scope_id: 3,
span: main.rs:2:13: 2:18 (#0),
},
// expression 5, scope surrounding expression 4
Expr {
kind: Scope {
region_scope: Node(3),
hir_id: HirId(DefId(0:3 ~ main[26fd]::main).3),
value: e4,
},
ty: i32,
temp_scope_id: 3,
span: main.rs:2:13: 2:18 (#0),
},
// expression 6, block around statement
Expr {
kind: Block {
block: b0,
},
ty: (),
temp_scope_id: 8,
span: main.rs:1:11: 3:2 (#0),
},
// expression 7, scope around block in expression 6
Expr {
kind: Scope {
region_scope: Node(8),
hir_id: HirId(DefId(0:3 ~ main[26fd]::main).8),
value: e6,
},
ty: (),
temp_scope_id: 8,
span: main.rs:1:11: 3:2 (#0),
},
],
stmts: [
Stmt {
kind: Let {
remainder_scope: Remainder { block: 1, first_statement_index: 0},
init_scope: Node(2),
pattern: Pat {
ty: i32,
span: main.rs:2:9: 2:10 (#0),
extra: None,
kind: Binding {
name: "x",
mode: BindingMode(
No,
Not,
),
var: LocalVarId(
HirId(DefId(0:3 ~ main[26fd]::main).7),
),
ty: i32,
subpattern: None,
is_primary: true,
is_shorthand: false,
},
},
initializer: Some(
e5,
),
else_block: None,
hir_id: HirId(DefId(0:3 ~ main[26fd]::main).6),
span: main.rs:2:5: 2:18 (#0),
},
},
],
params: [],
}
}
MIR(중간 수준 IR)
MIR은 Rust의 중간 수준 중간 표현(Mid-level Intermediate Representation)입니다. MIR은 HIR로부터 만들어집니다. MIR은 RFC 1211에서 도입되었습니다. MIR은 Rust를 대폭 단순화한 형태로, 특정 흐름 민감(flow-sensitive) 안전성 검사(특히 빌림 검사기!)와 최적화 및 코드 생성에 사용됩니다.
MIR과 MIR이 기반으로 하는 컴파일러 개념(제어 흐름 그래프, 디슈가링 등)을 아주 개략적으로 소개하는 글을 원하신다면 MIR을 소개한 rust-lang 블로그 글을 읽어 보십시오.
MIR 소개
MIR은 compiler/rustc_middle/src/mir/ 모듈에 정의되어 있지만, MIR을 다루는 코드의 상당 부분은 compiler/rustc_mir_build, compiler/rustc_mir_transform, compiler/rustc_mir_dataflow에 있습니다.
MIR의 주요 특징은 다음과 같습니다.
- 제어 흐름 그래프를 기반으로 합니다.
- 중첩된 표현식이 없습니다.
- MIR의 모든 타입은 완전히 명시적입니다.
주요 MIR 용어
이 절에서는 MIR의 주요 개념을 소개합니다. 요약하면 다음과 같습니다.
- 기본 블록(Basic block): 제어 흐름 그래프의 단위로, 다음으로 구성됩니다.
- 문(statement): 후속 지점이 하나인 동작입니다
- 종결자(terminator): 후속 지점이 여러 개일 수 있는 동작이며, 항상 블록의 끝에 위치합니다
- (기본 블록이라는 용어가 익숙하지 않다면 배경 지식 장을 참고하십시오)
- 로컬: 함수 인자, 지역 변수, 임시 값처럼 (적어도 개념적으로는) 스택에 할당되는 메모리 위치입니다. 로컬은 인덱스로 식별되며,
_1처럼 앞에 밑줄을 붙여 표기합니다. 반환 값을 저장하기 위해 할당되는 특별한 “로컬”(_0)도 있습니다. - 플레이스(Place):
_1이나_1.f처럼 메모리의 위치를 식별하는 표현식입니다. - R값(Rvalue): 값을 만들어 내는 표현식입니다. “R“은 이 표현식이 대입문의 “오른쪽(right-hand side)“에 온다는 뜻입니다.
- 피연산자: rvalue에 전달되는 인자로, 상수(
22등)이거나 place(_1등)일 수 있습니다.
- 피연산자: rvalue에 전달되는 인자로, 상수(
간단한 프로그램을 MIR로 변환하고 보기 좋게 출력된 결과를 읽어 보면 MIR이 어떻게 구성되는지 감을 잡을 수 있습니다. 실제로 플레이그라운드는 프로그램의 MIR을 보여 주는 MIR 버튼을 제공하므로 이를 쉽게 해 볼 수 있습니다. 이 프로그램을 플레이그라운드에 넣고(또는 이 링크를 클릭하고) 상단의 “MIR” 버튼을 클릭해 보십시오.
fn main() {
let mut vec = Vec::new();
vec.push(1);
vec.push(2);
}
다음과 비슷한 결과가 나타날 것입니다.
// WARNING: This output format is intended for human consumers only
// and is subject to change without notice. Knock yourself out.
fn main() -> () {
...
}
이것은 main 함수의 MIR 형식입니다. 위 링크에서 보여 주는 MIR은 최적화된 것입니다. StorageLive와 같은 일부 문장은 최적화 과정에서 제거됩니다. 컴파일러가 코드에서 해당 값에 전혀 접근하지 않는다는 것을 알아차리기 때문입니다. 최적화되지 않은 MIR을 보려면 rustc [filename].rs -Z mir-opt-level=0 --emit mir 명령을 사용할 수 있습니다. 이를 위해서는 nightly 툴체인이 필요합니다.
변수 선언. 조금 더 자세히 살펴보면 여러 개의 변수 선언으로 시작한다는 것을 알 수 있습니다. 변수 선언은 다음과 같습니다:
let mut _0: (); // return place
let mut _1: std::vec::Vec<i32>; // in scope 0 at src/main.rs:2:9: 2:16
let mut _2: ();
let mut _3: &mut std::vec::Vec<i32>;
let mut _4: ();
let mut _5: &mut std::vec::Vec<i32>;
MIR의 변수에는 이름이 없고, 대신 _0이나 _1과 같은 인덱스가 있다는 것을 알 수 있습니다. 또한 사용자 변수(예: _1)와 임시 값(예: _2 또는 _3)이 서로 섞여 있습니다. 사용자 정의 변수에는 debuginfo가 연결되어 있으므로 이를 통해 구별할 수 있습니다(아래 참조).
사용자 변수 debuginfo. 변수 선언 아래에서 _1이 사용자 변수를 나타낸다는 유일한 단서를 찾을 수 있습니다:
scope 1 {
debug vec => _1; // in scope 1 at src/main.rs:2:9: 2:16
}
각 debug <Name> => <Place>; 주석은 이름이 있는 사용자 변수와, 디버거가 해당 변수의 데이터를 찾을 수 있는 위치(즉, place)를 설명합니다. 여기서는 매핑이 단순하지만, 최적화로 인해 place가 복잡해지거나 여러 사용자 변수가 같은 place를 공유하게 될 수 있습니다. 또한 클로저 캡처도 같은 체계로 설명되므로 최적화를 하지 않더라도 복잡합니다. 예: debug x => (*((*_1).0: &T));.
“scope” 블록(예: scope 1 { .. })은 소스 프로그램의 어휘 구조(어떤 이름이 언제 스코프 안에 있었는지)를 설명합니다. 따라서 예를 들어 디버거에서 코드를 단계별로 실행한다면, // in scope 0 주석이 달린 프로그램의 모든 부분에서는 vec이 보이지 않습니다.
기본 블록. 계속 읽어 나가면 첫 번째 기본 블록이 보입니다(직접 볼 때는 당연히 약간 다르게 보일 수 있으며, 일부 주석은 생략했습니다):
bb0: {
StorageLive(_1);
_1 = const <std::vec::Vec<T>>::new() -> bb2;
}
기본 블록은 일련의 문장과 마지막 종결자로 정의됩니다. 이 경우에는 문장이 하나 있습니다:
StorageLive(_1);
이 문장은 변수 _1이 “살아 있음”(live), 즉 나중에 사용될 수 있음을 나타냅니다. 이 상태는 변수 _1의 사용이 끝났음을 나타내는 StorageDead(_1) 문장을 만날 때까지 유지됩니다. 이러한 “저장소 문장“은 LLVM이 스택 공간을 할당하는 데 사용합니다.
블록 bb0의 종결자는 Vec::new 호출입니다:
_1 = const <std::vec::Vec<T>>::new() -> bb2;
종결자는 둘 이상의 후속 블록을 가질 수 있다는 점, 즉 제어가 여러 곳으로 흐를 수 있다는 점에서 문장과 다릅니다. Vec::new 호출과 같은 함수 호출은 언와인딩(unwinding) 가능성 때문에 항상 종결자입니다. 다만 Vec::new의 경우에는 실제로 언와인딩이 불가능하다는 것을 알 수 있으므로 후속 블록으로 bb2 하나만 나열합니다.
뒤에 나오는 bb2를 미리 살펴보면 다음과 같습니다:
bb2: {
StorageLive(_3);
_3 = &mut _1;
_2 = const <std::vec::Vec<T>>::push(move _3, const 1i32) -> [return: bb3, unwind: bb4];
}
여기에는 두 개의 문장이 있습니다. 하나는 _3 임시 값을 도입하는 또 다른 StorageLive이고, 다른 하나는 대입입니다:
_3 = &mut _1;
대입은 일반적으로 다음과 같은 형태입니다:
<Place> = <Rvalue>
place는 _3, _3.f, *_3과 같은 표현식으로, 메모리상의 위치를 나타냅니다. Rvalue는 값을 생성하는 표현식입니다. 이 경우 rvalue는 &mut <Place> 형태의 가변 빌림 표현식입니다. 따라서 rvalue의 문법을 대략 다음과 같이 정의할 수 있습니다:
<Rvalue> = & (mut)? <Place>
| <Operand> + <Operand>
| <Operand> - <Operand>
| ...
<Operand> = Constant
| copy Place
| move Place
이 문법에서 볼 수 있듯이 rvalue는 중첩될 수 없으며, 오직 place와 상수만 참조할 수 있습니다. 또한 place를 사용할 때는 그것을 복사하는지(이 경우 place의 타입이 T: Copy를 만족하는 T여야 합니다), 아니면 이동하는지(이 경우 어떤 타입의 place에도 사용할 수 있습니다)를 표시합니다. 예를 들어 Rust에서 x = a + b + c라는 표현식이 있다면, 이는 두 개의 문장과 하나의 임시 변수로 컴파일됩니다:
TMP1 = a + b
x = TMP1 + c
(직접 시도해 보십시오. 다만 오버플로 검사를 건너뛰려면 릴리스 모드를 사용하는 것이 좋습니다.)
MIR 데이터 타입
MIR 데이터 타입은 compiler/rustc_middle/src/mir/ 모듈에 정의되어 있습니다. 앞 절에서 언급한 핵심 개념들은 각각 꽤 직관적인 방식으로 Rust 타입에 대응됩니다.
주요 MIR 데이터 타입은 Body입니다. 이 타입은 단일 함수에 대한 데이터를 담고 있습니다(“승격된 상수(promoted constants)“를 위한 Mir 하위 인스턴스도 함께 담고 있지만, 이에 대해서는 아래에서 읽을 수 있습니다).
- 기본 블록: 기본 블록은
Body::basic_blocks필드에 저장되며, 이는BasicBlockData구조체의 벡터입니다. 기본 블록을 직접 참조하는 일은 없습니다. 대신 이 벡터에 대한 newtype으로 감싼 인덱스인BasicBlock값을 주고받습니다. - 문장은
Statement타입으로 표현됩니다. - 종결자는
Terminator로 표현됩니다. - 로컬은 newtype으로 감싼 인덱스 타입인
Local로 표현됩니다. 지역 변수의 데이터는Body::local_decls벡터에 있습니다. 또한 반환 값을 나타내는 특수한 “로컬“을 식별하는 특수 상수RETURN_PLACE도 있습니다. - Place는
Place구조체로 식별됩니다. 몇 가지 필드가 있습니다:_1같은 지역 변수- 프로젝션: 기본 place로부터 “투영되어 나오는” 필드나 그 밖의 요소입니다. 이는 newtype으로 감싼 타입인
ProjectionElem으로 표현됩니다. 예를 들어 place_1.f는 프로젝션이며, 여기서f는 “프로젝션 요소“이고_1은 기본 경로입니다.*_1역시 프로젝션이며, 여기서*는ProjectionElem::Deref요소로 표현됩니다.
- Rvalue는 열거형
Rvalue로 표현됩니다. - 피연산자는 열거형
Operand로 표현됩니다.
상수 표현하기
코드가 MIR 단계에 도달하면 상수는 일반적으로 MIR 상수(mir::Constant)와 타입 시스템 상수(ty::Const)의 두 가지 형태로 나타납니다. MIR 상수는 피연산자로 사용됩니다. x + CONST에서 CONST는 MIR 상수이며, 마찬가지로 x + 2에서 2도 MIR 상수입니다. 타입 시스템 상수는 타입 시스템에서 사용되며, 특히 배열 길이에 사용되지만 상수 제네릭에도 사용됩니다.
일반적으로 두 종류의 상수 모두 “평가되지 않은” 상태이거나 “이미 평가된” 상태일 수 있습니다. 평가되지 않은 상수는 이 결과를 계산하기 위해 평가해야 하는 대상의 DefId를 저장할 뿐입니다. 평가된 상수(“값”)는 이미 계산된 상태이며, 그 표현은 타입 시스템 상수와 MIR 상수 사이에 차이가 있습니다. MIR 상수는 mir::ConstValue로 평가되고, 타입 시스템 상수는 ty::ValTree로 평가됩니다.
타입 시스템 상수에는 상수 제네릭을 지원하기 위한 배리언트가 몇 가지 더 있습니다. 타입 시스템 상수는 지역 상수 제네릭 매개변수를 참조할 수 있으며, 추론의 대상이 됩니다. 또한 mir::Constant::Ty 배리언트를 사용하면 임의의 타입 시스템 상수를 MIR 상수로 사용할 수 있습니다. 이는 상수 제네릭 매개변수가 피연산자로 사용될 때마다 발생합니다.
MIR 상수 값
일반적으로 MIR 상수 값(mir::ConstValue)은 사용자가 작성한 어떤 상수를 평가하여 계산된 것입니다. 이 상수 평가는 결과를 개별 바이트 단위로 나타내는 매우 저수준의 표현을 생성합니다. 값이 메모리에 저장되므로 이를 “간접” 상수(mir::ConstValue::Indirect)라고 부릅니다.
그러나 모든 것을 메모리에 저장하는 것은 매우 비효율적입니다. 따라서 mir::ConstValue에는 단순하고 흔한 특정 값을 더 효율적으로 표현할 수 있는 다른 변형이 몇 가지 있습니다. 특히 Rust에서 리터럴로 직접 작성할 수 있는 모든 것(정수, 부동소수점 수, 문자, 불리언뿐만 아니라 "string literals"와 b"byte string literals"도 포함)에는 메모리 내 표현의 전체 오버헤드를 피하는 최적화된 변형이 있습니다.
ValTree
평가된 타입 시스템 상수는 “valtree“입니다. ty::ValTree 자료 구조를 사용하면 다음을 표현할 수 있습니다.
- 배열,
- 많은 구조체,
- 튜플,
- 열거형,
- 대부분의 원시 타입.
이 표현에서 가장 중요한 규칙은 모든 값이 고유하게 표현되어야 한다는 것입니다. 다시 말해, 특정한 값은 오직 한 가지 특정한 방식으로만 표현될 수 있어야 합니다. 예를 들어, 두 정수로 이루어진 배열을 ValTree로 표현하는 방법은 Branch([Leaf(first_int), Leaf(second_int)]) 한 가지뿐입니다. 이론적으로는 [u32; 2]를 u64로 인코딩하여 단순히 Leaf(bits_of_two_u32)로 만들 수도 있겠지만, 이는 ValTree의 올바른 구성이 아닙니다(또한 그렇게 하기가 매우 복잡하므로 누군가 그렇게 하고 싶어 할 가능성은 낮습니다).
이러한 규칙은 또한 일부 값은 표현할 수 없다는 것을 의미합니다. 타입 수준 상수에는 union이 있을 수 없습니다. 활성 변형을 알 수 없기 때문에 어떻게 표현해야 할지 명확하지 않기 때문입니다. 마찬가지로 컴파일 시점에는 주소를 알 수 없으므로 원시 포인터를 표현할 방법이 없으며, 따라서 원시 포인터에 대해서는 어떠한 가정도 할 수 없습니다. 반면 참조는 표현될 수 있습니다. 참조의 동등성은 그 값의 동등성으로 정의되므로, 주소는 무시하고 참조가 가리키는 값만 살펴보면 되기 때문입니다. 참조의 포인터 값이 컴파일 시점에 관찰될 수 없도록 보장해야 합니다. 따라서 &42를 42와 정확히 동일하게 인코딩합니다. valtree를 다시 MIR 상수 값으로 변환할 때는 반드시 실제 간접 참조를 다시 도입해야 합니다. 코드 생성 시점에 주소는 여러 사용처 간에 중복 제거될 수도 있고 그렇지 않을 수도 있으며, 이는 전적으로 임의의 최적화 선택에 달려 있습니다.
결과적으로 ValTree의 모든 디코딩은 먼저 타입에 대해 매칭하고 그에 따라 결정을 내리는 방식으로 이루어져야 합니다. 값 자체는 그 값에 해당하는 타입 없이는 어떠한 유용한 정보도 제공하지 않습니다.
승격된 상수
const-eval WG의 승격(promotion) 관련 문서를 참고하십시오.
MIR 구성
HIR에서 MIR로의 로워링은 다음 항목들에 대해 일어납니다(이 목록은 완전하지 않을 수 있습니다):
- 함수 및 클로저 본문
static및const항목의 초기화 식- 열거형 판별값의 초기화 식
- 모든 종류의 글루(glue)와 심(shim)
- 튜플 구조체 초기화 함수
- 드롭 코드(
Drop::drop함수는 직접 호출되지 않습니다) - 명시적인
Drop구현이 없는 타입의 드롭 구현
로워링은 mir_built 쿼리를 호출하면 시작됩니다. MIR 빌더는 실제로 HIR을 사용하지 않고 THIR에서 동작하며, THIR 표현식을 재귀적으로 처리합니다.
로워링은 먼저 시그니처에 명시된 모든 인자에 대해 지역 변수를 생성합니다. 다음으로, 지정된 모든 바인딩에 대해 지역 변수를 생성합니다. 예를 들어 (a, b): (i32, String)은 바인딩 3개를 만드는데, 하나는 인자에 대한 것이고 두 개는 바인딩에 대한 것입니다. 그다음에는 인자에서 필드를 읽는 필드 접근을 생성하고, 그 값을 바인딩 변수에 씁니다.
이 초기화가 끝나면, 로워링은 본문(Block 표현식)에 대한 MIR을 생성하는 함수를 재귀적으로 호출하고, 그 결과를 RETURN_PLACE에 씁니다.
모든 것에 unpack!을
MIR을 생성하는 함수는 대개 두 가지 패턴 중 하나에 속합니다. 첫째, 함수가 문(statement)만 생성한다면 그 문들을 덧붙일 기본 블록을 인자로 받습니다. 그런 다음 평소처럼 결과를 반환할 수 있습니다:
fn generate_some_mir(&mut self, block: BasicBlock) -> ResultType {
...
}
하지만 새로운 기본 블록까지 생성할 수 있는 함수들도 있습니다. 예를 들어 if foo { 22 } else { 44 } 같은 표현식을 로워링하려면 작은 “다이아몬드 모양 그래프“를 생성해야 합니다. 이 경우 함수는 코드가 시작되는 기본 블록을 받고, 코드 생성이 끝나는 (새로운 것일 수도 있는) 기본 블록을 반환합니다. 이를 표현하는 데 BlockAnd 타입이 사용됩니다:
fn generate_more_mir(&mut self, block: BasicBlock) -> BlockAnd<ResultType> {
...
}
이러한 함수를 호출할 때는 사실상 “커서” 역할을 하는 지역 변수 block을 두는 것이 일반적입니다. 이 변수는 새 MIR을 추가하고 있는 지점을 나타냅니다. generate_more_mir를 호출할 때는 이 커서를 갱신해야 합니다. 이를 수동으로 할 수도 있지만 번거롭습니다:
let mut block;
let v = match self.generate_more_mir(..) {
BlockAnd { block: new_block, value: v } => {
block = new_block;
v
}
};
이러한 이유로 let v = unpack!(block = self.generate_more_mir(...))처럼 작성할 수 있게 해 주는 매크로를 제공합니다. 이 매크로는 단순히 새 블록을 추출하여 unpack!에서 지정한 변수 block을 덮어씁니다.
표현식을 원하는 MIR로 로워링하기
표현식에 대해 원할 수 있는 표현 형태는 본질적으로 네 가지가 있습니다:
Place는 이미 존재하는 메모리 위치(또는 그 일부)를 가리킵니다(지역 변수, 정적 변수, 승격된 값).Rvalue는Place에 대입할 수 있는 것입니다Operand는+연산이나 함수 호출 등의 인자입니다- 값의 복사본을 담고 있는 임시 변수입니다
다음 이미지는 각 표현 사이의 상호작용을 개략적으로 보여 줍니다:
RETURN_PLACE에 대한 대입을 만들 수 있도록, 먼저 함수 본문을 Rvalue로 로워링합니다. 이 Rvalue 로워링은 다시 인자(있는 경우)를 Operand로 로워링하도록 합니다. Operand 로워링은 const 피연산자를 만들거나, Place에서 값을 이동 또는 복사하며, 후자의 경우 Place 로워링이 일어납니다. Place로 낮춰지는 표현식은 연산을 포함하고 있으면 다시 임시 값의 생성을 일으킬 수 있습니다. 바로 여기서 뱀이 자기 꼬리를 무는 상황이 됩니다. 지역 변수에 쓸 표현식에 대해 Rvalue 로워링을 일으켜야 하기 때문입니다.
연산자 로워링
내장 타입에 대한 연산자는 함수 호출로 낮춰지지 않습니다(트레이트 impl에는 그 연산 자체가 다시 들어 있을 뿐이므로, 그렇게 하면 결국 무한 재귀 호출이 됩니다). 대신 바이너리 연산자와 단항 연산자, 그리고 인덱스 연산을 위한 Rvalue가 있습니다. 이 Rvalue들은 이후 llvm 기본 연산이나 llvm 내장 함수(intrinsic)로 코드 생성됩니다.
그 밖의 모든 타입에 대한 연산자는 해당 연산자에 대응하는 트레이트의 impl에 대한 함수 호출로 낮춰집니다.
로워링 종류와 관계없이 연산자의 인자는 Operand로 로워링됩니다. 즉, 모든 인자는 상수이거나, 로컬 또는 정적 변수 어딘가에 이미 존재하는 값을 가리킵니다.
메서드 호출 로워링
메서드 호출은 함수 호출과 동일한 TerminatorKind로 낮춰집니다. MIR에서는 더 이상 메서드 호출과 함수 호출 사이에 차이가 없습니다.
조건
if 조건과, 필드가 없는 배리언트만 가진 enum에 대한 match 문은 TerminatorKind::SwitchInt로 낮춰집니다. 가능한 각 값(if 조건의 경우 0과 1)마다 코드가 이어서 실행될 BasicBlock이 하나씩 대응합니다. 분기의 기준이 되는 인자는 (역시) if 조건의 값을 나타내는 Operand입니다.
패턴 매칭
필드가 있는 배리언트를 가진 enum에 대한 match 문 역시 TerminatorKind::SwitchInt로 낮춰지지만, 이때 Operand는 값의 판별값(discriminant)을 찾을 수 있는 Place를 가리킵니다. 이를 위해 판별값을 새 임시 변수로 읽어 들이는 경우가 많습니다.
집합체 생성
모든 종류의 집합체 값(예: 구조체나 튜플)은 Rvalue::Aggregate를 통해 만들어집니다. 모든 필드는 Operator로 낮춰집니다. 이는 본질적으로 집합체 필드마다 대입문을 하나씩 두고, enum의 경우 판별값에 대한 대입을 하나 더 두는 것과 같습니다.
MIR 방문자
MIR 방문자는 MIR을 순회하면서 무언가를 찾거나 MIR을 변경할 때 쓰는 편리한 도구입니다. 방문자 트레이트는 rustc_middle::mir::visit 모듈에 정의되어 있습니다. 하나의 매크로로 생성되는 트레이트가 두 가지 있는데, &Mir에서 동작하며 공유 참조를 돌려주는 Visitor와 &mut Mir에서 동작하며 가변 참조를 돌려주는 MutVisitor입니다.
방문자를 구현하려면 해당 방문자를 나타내는 타입을 만들어야 합니다. 일반적으로 이 타입은 MIR을 처리하는 동안 필요한 상태를 모두 “붙잡아” 둡니다:
struct MyVisitor<...> {
tcx: TyCtxt<'tcx>,
...
}
그런 다음 해당 타입에 대해 Visitor 또는 MutVisitor 트레이트를 구현합니다:
impl<'tcx> MutVisitor<'tcx> for MyVisitor {
fn visit_foo(&mut self, ...) {
...
self.super_foo(...);
}
}
위에서 보듯이 impl 안에서 visit_foo 메서드(예: visit_terminator) 중 어느 것이든 오버라이드하여, foo가 발견될 때마다 실행될 코드를 작성할 수 있습니다. foo의 내용을 재귀적으로 순회하려면 super_foo 메서드를 호출하십시오. super_foo는 절대 오버라이드하지 않아야 한다는 점에 유의하십시오.
아주 간단한 방문자 예시는 LocalFinder에서 찾을 수 있습니다. 이 방문자는 visit_local 메서드를 구현하여 재정렬 후보가 될 수 있는 지역 변수를 식별합니다.
순회
방문자 외에도 rustc_middle::mir::traversal 모듈에는 MIR CFG를 여러 표준 순서(예: 전위 순서, 역후위 순서 등)로 순회하는 데 유용한 함수들이 들어 있습니다.
MIR 쿼리와 패스
MIR을 얻으려면 다음과 같이 하십시오.
- 함수의 경우
optimized_mir쿼리(주로 코드 생성에서 사용됨)나mir_for_ctfe쿼리(주로 컴파일 타임 함수 평가, 즉 CTFE에서 사용됨)를 사용할 수 있습니다. - 승격된 값(promoted)의 경우
promoted_mir쿼리를 사용할 수 있습니다.
이 쿼리들은 최적화가 끝난 최종 MIR을 돌려줍니다. 외부(foreign) def-id의 경우에는 다른 크레이트의 메타데이터에서 MIR을 읽어 오기만 합니다. 하지만 로컬 def-id의 경우, 쿼리는 업스트림 쿼리의 파이프라인을 요청하여 최적화된 MIR을 구성합니다1. 각 쿼리에는 일련의 패스가 들어 있습니다. 이 절에서는 이러한 쿼리와 패스가 어떻게 동작하는지, 그리고 이를 어떻게 확장할 수 있는지 설명합니다.
주어진 def-id D에 대해 최적화된 MIR을 만들기 위해 optimized_mir(D)는 여러 패스 묶음(suite)을 거치며, 각 묶음은 하나의 쿼리로 그룹화되어 있습니다. 각 묶음은 린트, 분석, 변환 또는 최적화를 수행하는 패스들로 이루어져 있습니다. 각 쿼리는 타입 검사나 그 밖의 목적을 위해 MIR 방언(dialect)에 접근할 수 있는 유용한 중간 지점을 나타냅니다.
mir_built(D)– 빌드된 직후의 초기 MIR을 제공합니다.mir_const(D)– 몇 가지 간단한 변환 패스를 적용하여 MIR을 const 한정(const qualification)에 대비한 상태로 만듭니다.mir_promoted(D)- 승격 가능한 임시값을 별도의 MIR 본문으로 추출하고, MIR을 빌림 검사에 대비한 상태로 만듭니다.mir_drops_elaborated_and_const_checked(D)- 빌림 검사를 수행하고, (드롭 정교화와 같은) 주요 변환 패스를 실행하며, MIR을 최적화할 준비가 된 상태로 만듭니다.optimized_mir(D)– 활성화된 모든 최적화를 수행하여 최종 상태에 이릅니다.
패스 구현 및 등록
MirPass는 MIR을 처리하는 코드 조각으로, 보통 그 과정에서 MIR을 어떤 식으로든 변환합니다. 하지만 린트(예: MirLint를 구현하는 CheckPackedRef, CheckConstItemMutation, FunctionItemReferences)나 최적화(예: SimplifyCfg, RemoveUnneededDrops) 같은 다른 일을 할 수도 있습니다. 대부분의 MIR 패스는 rustc_mir_transform 크레이트에 정의되어 있지만, MirPass 트레이트 자체는 rustc_middle 크레이트에 들어 있으며, 기본적으로 (tcx와 함께) &mut Body를 받기만 하는 주요 메서드 run_pass 하나로 구성됩니다. 따라서 MIR은 제자리에서 수정됩니다(이는 효율을 유지하는 데 도움이 됩니다).
MIR 패스의 기본적인 예로 RemoveStorageMarkers가 있는데, 이 패스는 MIR을 순회하며 코드 생성 중에 스토리지 마크가 내보내지지 않을 경우 이를 모두 제거합니다. 소스에서 볼 수 있듯이, MIR 패스는 먼저 더미 타입, 즉 필드가 없는 구조체를 정의하는 것으로 정의됩니다.
#![allow(unused)]
fn main() {
pub struct RemoveStorageMarkers;
}
그리고 이 구조체에 대해 MirPass 트레이트를 구현합니다. 그런 다음 이 패스를 mir_built, optimized_mir 등의 쿼리에 있는 적절한 패스 목록에 삽입할 수 있습니다. (최적화 패스라면 optimized_mir 목록에 넣어야 합니다.)
간단한 MIR 패스의 또 다른 예로 CleanupPostBorrowck가 있는데, 이 패스는 MIR을 순회하며 코드 생성과 관련 없는 문장(statement)을 모두 제거합니다. 소스에서 볼 수 있듯이, 이 패스는 먼저 더미 타입, 즉 필드가 없는 구조체를 정의하는 것으로 정의됩니다.
#![allow(unused)]
fn main() {
pub struct CleanupPostBorrowck;
}
그리고 이 구조체에 대해 MirPass 트레이트를 구현합니다.
#![allow(unused)]
fn main() {
impl<'tcx> MirPass<'tcx> for CleanupPostBorrowck {
fn run_pass(&self, tcx: TyCtxt<'tcx>, body: &mut Body<'tcx>) {
...
}
}
}
이 패스는 mir_drops_elaborated_and_const_checked 쿼리 안에서 등록합니다. (최적화 패스라면 optimized_mir 목록에 넣어야 합니다.)
패스를 작성하고 있다면 MIR 방문자를 사용하고 싶어질 가능성이 높습니다. MIR 방문자는 무언가를 검색하거나 작은 수정을 가하기 위해 MIR의 모든 부분을 순회하는 편리한 방법입니다.
훔치기
중간 쿼리인 mir_const()와 mir_promoted()는 tcx.alloc_steal_mir()를 사용해 할당된 &'tcx Steal<Body<'tcx>>를 반환합니다. 이는 결과가 후속 쿼리에 의해 훔쳐질 수 있음을 나타내며, MIR 복제를 피하기 위한 최적화입니다. 훔쳐진 결과를 사용하려고 하면 컴파일러에서 패닉이 발생합니다. 따라서 MIR 처리 파이프라인의 의존성을 고려하지 않은 채 이러한 중간 쿼리에서 실수로 값을 읽지 않도록 하는 것이 중요합니다.
이러한 훔치기 메커니즘 때문에, 처리 파이프라인의 특정 단계에 있는 MIR이 훔쳐지기 전에 그것을 읽으려는 모든 쪽이 이미 읽기를 마쳤는지 주의 깊게 확인해야 합니다.
구체적으로 말하면, mir_promoted(D)의 결과에 접근하려는 쿼리 foo(D)가 있다면 foo(D)가 mir_const(D) 쿼리를 먼저 호출하도록 해야 합니다. 이렇게 하면 그 결과가 직접 필요하지 않더라도 해당 쿼리가 강제로 실행됩니다.
이 메커니즘은 다소 미덥지 못합니다. 더 우아한 대안에 대한 논의는 rust-lang/rust#41710에서 볼 수 있습니다.
개요
다음은 MIR 처리 파이프라인에서 훔치기 의존성의 개요입니다2:
flowchart BT mir_for_ctfe* --borrow--> id40 id5 --steal--> id40 mir_borrowck* --borrow--> id3 id41 --steal part 1--> id3 id40 --steal part 0--> id3 mir_const_qualif* -- borrow --> id2 id3 -- steal --> id2 id2 -- steal --> id1 id1([mir_built]) id2([mir_const]) id3([mir_promoted]) id40([mir_drops_elaborated_and_const_checked]) id41([promoted_mir]) id5([optimized_mir]) style id1 fill:#bbf style id2 fill:#bbf style id3 fill:#bbf style id40 fill:#bbf style id41 fill:#bbf style id5 fill:#bbf
진한 색의 스타디움 모양 쿼리(예: mir_built)는 파이프라인의 주요 쿼리이며, 옅은 색의 직사각형 모양 쿼리(예: mir_const_qualif*3)는 &'tcx Steal<Body<'tcx>>에서 결과를 읽어야 하는 후속 쿼리입니다. 훔치기 메커니즘으로 인해, 직사각형 모양 쿼리는 의존성 트리에서 높이가 같거나 더 높은 스타디움 모양 쿼리가 실행되기 전에 먼저 수행되어야 합니다.
예시
예시로 MIR 상수 한정(const qualification)을 생각해 봅시다. 이는 mir_const 쿼리가 생성한 결과를 읽으려 합니다. 하지만 그 결과는 파이프라인의 어느 시점에 mir_promoted 쿼리에 의해 훔쳐집니다. mir_promoted가 쿼리되기 전에는 mir_const_qualif 쿼리 호출이 성공합니다. mir_const가 Steal 결과를 생성하거나(처음 쿼리된 경우) 캐시하고(여러 번 쿼리된 경우), 그 결과가 아직 훔쳐지지 않았기 때문입니다. mir_promoted가 쿼리된 후에는 결과가 훔쳐지므로, 결과를 읽기 위해 mir_const_qualif 쿼리를 호출하면 패닉이 발생합니다.
따라서 이 훔치기 메커니즘에서 mir_promoted는 실제로 훔치기 전에 모든 mir_const_qualif* 쿼리가 호출되도록 보장하여 읽기가 이미 이루어졌음을 확실히 해야 합니다(쿼리는 메모이제이션된다는 점을 기억하십시오. 따라서 쿼리를 두 번 실행하면 두 번째에는 단순히 캐시에서 불러옵니다).
-
mir_promoted쿼리는 튜플(&'tcx Steal<Body<'tcx>>, &'tcx Steal<IndexVec<Promoted, Body<'tcx>>>)를 반환하며,promoted_mir는 1번 부분(&'tcx Steal<IndexVec<Promoted, Body<'tcx>>>)을,mir_drops_elaborated_and_const_checked는 0번 부분(&'tcx Steal<Body<'tcx>>)을 훔칩니다. 그리고 이 둘의 훔치기는 서로 무관하므로 따로 수행될 수 있습니다. ↩ -
쿼리 이름의
*접미사는 같은 접두사를 가진 쿼리들의 집합을 나타낸다는 점에 유의하십시오. 예를 들어mir_borrowck*는mir_borrowck,mir_borrowck_const_arg,mir_borrowck_opt_const_arg를 나타냅니다. ↩
인라인 어셈블리
개요
rustc의 인라인 어셈블리는 주로 asm! 매크로 호출을 받아 컴파일러의 모든 계층을 거쳐 LLVM 코드 생성까지 전달하는 작업을 중심으로 이루어집니다. 여러 단계에 걸쳐 InlineAsm은 일반적으로 다음 3가지 구성 요소로 이루어집니다.
-
InlineAsmTemplatePiece의 배열로 저장되는 템플릿 문자열입니다. 각 조각은 리터럴이거나 피연산자를 위한 자리 표시자입니다(형식 문자열과 마찬가지입니다).#![allow(unused)] fn main() { pub enum InlineAsmTemplatePiece { String(String), Placeholder { operand_idx: usize, modifier: Option<char>, span: Span }, } } -
asm!에 전달되는 피연산자 목록입니다(in,[late]out,in[late]out,sym,const). 이들은 로워링(lowering)의 각 단계마다 다르게 표현되지만, 다음과 같은 공통된 패턴을 따릅니다.in,out,inout은 모두 연관된 레지스터 클래스(reg) 또는 명시적 레지스터("eax")를 가집니다.inout에는 2가지 형태가 있습니다. 하나는 읽기와 쓰기가 모두 이루어지는 단일 표현식을 사용하는 형태이고, 다른 하나는 입력 부분과 출력 부분에 각각 별도의 표현식을 사용하는 형태입니다.out과inout에는 레지스터 할당기가 이 출력에 입력 레지스터를 재사용해도 됨을 나타내는late플래그(lateout/inlateout)가 있습니다.out과 분리된 형태의inout에서는 출력으로_를 지정할 수 있으며, 이는 해당 출력을 버린다는 의미입니다. 이는 어셈블리 코드에서 사용할 스크래치 레지스터를 할당하는 데 쓰입니다.const는 익명 상수를 가리키며, 일반적으로 인라인 const처럼 동작합니다.sym은 경로 표현식만 받으며 그 경로가 반드시static또는fn을 가리켜야 하므로 약간 특별합니다.
-
asm!매크로의 끝에 설정되는 옵션입니다. 이 중 rustc에서 특히 중요한 옵션은asm!이()대신!를 반환하게 하는NORETURN과 형식 문자열 파싱을 비활성화하는RAW뿐입니다. 나머지 옵션은 대부분 별다른 처리 없이 LLVM으로 전달됩니다.#![allow(unused)] fn main() { bitflags::bitflags! { pub struct InlineAsmOptions: u16 { const PURE = 1 << 0; const NOMEM = 1 << 1; const READONLY = 1 << 2; const PRESERVES_FLAGS = 1 << 3; const NORETURN = 1 << 4; const NOSTACK = 1 << 5; const ATT_SYNTAX = 1 << 6; const RAW = 1 << 7; const MAY_UNWIND = 1 << 8; } } }
AST
InlineAsm은 AST에서 ast::InlineAsm 타입을 가진 표현식으로 표현됩니다.
asm! 매크로는 rustc_builtin_macros에 구현되어 있으며 InlineAsm AST 노드를 출력합니다. 템플릿 문자열은 fmt_macros를 사용하여 파싱되며, 위치 피연산자와 이름 있는 피연산자는 명시적인 피연산자 인덱스로 해석됩니다. 매크로 호출에서는 타깃 정보를 사용할 수 없으므로, 레지스터와 레지스터 클래스의 검증은 AST 로워링 단계로 미뤄집니다.
HIR
InlineAsm은 HIR에서 hir::InlineAsm 타입을 가진 표현식으로 표현됩니다.
AST 로워링 단계에서 InlineAsmRegOrRegClass가 Symbol에서 실제 레지스터 또는 레지스터 클래스로 변환됩니다. 템플릿 문자열 자리 표시자에 수정자가 지정된 경우, 해당 피연산자 타입에 허용되는 수정자 집합에 맞는지 검증합니다. 마지막으로, 입력과 출력에 사용된 명시적 레지스터가 서로 충돌하는지(서로 다른 피연산자에 같은 레지스터를 사용하는지) 검사합니다.
타입 검사
각 레지스터 클래스에는 함께 사용할 수 있는 타입의 허용 목록이 있습니다. 모든 피연산자의 타입이 결정되면 intrinsicck 패스가 이 타입들이 허용 목록에 있는지 검사합니다. 또한 분할된 inout 피연산자의 타입이 서로 호환되는지, const 피연산자가 정수나 부동소수점 수인지도 검사합니다. 피연산자에 전달된 타입으로 볼 때 그 피연산자에 템플릿 수정자를 사용해야 하는 경우, 필요한 곳에 제안을 출력합니다.
THIR
InlineAsm은 THIR에서 InlineAsmExpr 타입의 표현식으로 표현됩니다.
HIR과 비교했을 때 유일하게 중요한 변경점은 Sym이 expr이 fn의 Literal ZST인 SymFn, 또는 static의 DefId를 가리키는 SymStatic 중 하나로 낮춰졌다는 점입니다.
MIR
InlineAsm은 MIR에서 TerminatorKind::InlineAsm 배리언트를 사용하는 Terminator로 표현됩니다
THIR 로워링 과정에서 InOut 및 SplitInOut 피연산자는 in_value와 out_place가 분리된 분할 형태로 로워링됩니다.
의미론적으로 InlineAsm 종결자는 Call 종결자와 비슷하지만, Call의 출력은 반환 위치 하나뿐인 반면 InlineAsm에는 출력 위치가 여러 개 있다는 점이 다릅니다.
코드 생성
피연산자는 LLVM 코드 생성에 전달되기 전에 한 번 더 낮춰집니다. 이는 rustc_codegen_ssa의 InlineAsmOperandRef 타입으로 표현됩니다.
피연산자는 다음과 같이 LLVM 피연산자와 제약 코드로 낮춰집니다.
- LLVM의 요구에 따라
out피연산자와inout피연산자의 출력 부분이 가장 먼저 추가됩니다. 늦은(late) 출력 피연산자는 제약 코드에=접두사가 붙고, 늦지 않은 출력 피연산자는 제약 코드에=&접두사가 붙습니다. in피연산자는 일반적인 방식으로 추가됩니다.inout피연산자는 짝이 되는 출력 피연산자에 묶입니다.sym피연산자는"s"제약을 사용하여 함수 포인터나 포인터로 전달됩니다.const피연산자는 문자열로 포맷되어 템플릿 문자열에 바로 삽입됩니다.
템플릿 문자열은 다음과 같이 LLVM 형식으로 변환됩니다.
$문자는$$로 이스케이프됩니다.const피연산자는 문자열로 변환되어 바로 삽입됩니다.- 플레이스홀더는
${X:M}형식으로 포맷되며, 여기서X는 피연산자 인덱스이고M은 수정자 문자입니다. 수정자는 Rust 형식에서 LLVM 형식으로 변환됩니다.
여러 옵션은 클로버 제약이나 LLVM 속성으로 변환됩니다. 자세한 내용은 RFC를 참고하십시오.
LLVM은 특정 제약 코드에 어떤 타입을 허용하는지에 대해 꽤 까다로울 때가 있으므로, 지원되는 타입으로 변환하거나 지원되는 타입에서 변환하는 코드를 삽입해야 할 때가 있다는 점에 유의하십시오. 레지스터 클래스마다 어떤 타입이 지원되는지 자세히 알아보려면 LLVM의 타깃별 ISelLowering.cpp 파일을 참고하십시오.
새 아키텍처 지원 추가하기
아키텍처에 인라인 어셈블리 지원을 추가하는 작업은 대부분 그 아키텍처의 레지스터와 레지스터 클래스를 정의하는 일입니다. 레지스터 클래스의 정의는 모두 compiler/rustc_target/asm/에 있습니다.
또한 compiler/rustc_codegen_llvm/asm.rs에서 이러한 레지스터 클래스를 LLVM 제약 코드로 로워링하는 기능도 구현해야 합니다.
새 아키텍처를 추가할 때는 반드시 LLVM 소스 코드와 대조하여 확인하십시오.
- LLVM에는 특정 제약 조건 코드와 함께 사용할 수 있는 타입에 대한 제한이 있습니다.
lib/Target/${ARCH}/${ARCH}ISelLowering.cpp의getRegForInlineAsmConstraint함수를 참고하십시오. - LLVM은 특정 레지스터를 내부 용도로 예약하며, 이로 인해 해당 레지스터는 인라인 어셈블리 블록 전후에서 제대로 저장/복원되지 않습니다. 이러한 레지스터는
lib/Target/${ARCH}/${ARCH}RegisterInfo.cpp의getReservedRegs함수에 나열되어 있습니다. 함수에 프레임/베이스 포인터가 필요할지 미리 알 수 없으므로, 프레임/베이스 포인터와 같이 “조건부로” 예약되는 레지스터는 Rust에서는 항상 예약된 것으로 취급해야 합니다.
테스트
인라인 어셈블리에 대한 다양한 테스트가 있습니다:
tests/assembly-llvm/asmtests/ui/asmtests/codegen-llvm/asm-*
인라인 어셈블리가 지원하는 모든 아키텍처는 tests/assembly-llvm/asm에 레지스터 클래스와 타입의 모든 조합을 검사하는 포괄적인 테스트를 갖추어야 합니다.
명령줄 인자
명령줄 플래그는 rustc 책에 문서화되어 있습니다. 모든 안정 플래그는 그곳에 문서화되어야 합니다. 불안정 플래그는 unstable book에 문서화되어야 합니다.
새 명령줄 인자를 추가하는 절차에 대한 자세한 내용은 새 옵션에 관한 forge 가이드를 참조하십시오.
지침
- 플래그는 서로 직교해야 합니다. 예를 들어, 여러 동작
foo와bar에 JSON을 출력하는 변형이 있다면,--foo-json과--bar-json을 추가하는 것보다 별도의--json플래그를 두는 편이 낫습니다. no-접두사가 붙은 플래그는 피하십시오. 대신-C embed-bitcode=no처럼parse_bool함수를 사용하십시오.- 플래그가 여러 번 전달될 때의 동작을 고려하십시오. 어떤 경우에는 값이 (순서대로!) 누적되어야 합니다. 다른 경우에는 뒤에 오는 플래그가 앞선 플래그를 덮어써야 합니다(예: 린트 수준 플래그). 그리고 어떤 플래그(
-o등)는 여러 번 지정했을 때의 의미가 지나치게 모호하다면 오류를 발생시켜야 합니다. - 컴파일러 스크립트를 더 이해하기 쉽게 만들기 위해서라도, 옵션에는 항상 길고 설명적인 이름을 붙이십시오.
--verbose플래그는rustc출력에 상세 정보를 추가하기 위한 것입니다. 예를 들어,--version플래그와 함께 사용하면 컴파일러 코드의 해시에 대한 정보를 제공합니다.- 실험적인 플래그와 옵션은 반드시
-Z unstable-options플래그를 지정해야만 사용할 수 있도록 제한해야 합니다.
rustc_driver와 rustc_interface
rustc_driver
rustc_driver는 본질적으로 rustc의 main 함수입니다. 이는 rustc_interface 크레이트에 정의된 인터페이스를 사용하여 컴파일러의 여러 단계를 올바른 순서로 실행하도록 이어 주는 접착제 역할을 합니다. 가능하다면 rustc_interface보다 rustc_driver를 사용하는 것을 권장합니다.
rustc_driver의 주 진입점은 rustc_driver::run_compiler입니다. 이 빌더는 rustc와 동일한 명령줄 인수와 함께 Callbacks의 구현 및 몇 가지 다른 선택적 옵션을 받습니다. Callbacks는 사용자 정의 컴파일러 구성을 가능하게 하고, 컴파일의 여러 단계가 끝난 뒤에 사용자 정의 코드를 실행할 수 있게 해 주는 trait입니다.
rustc_interface
rustc_interface 크레이트는 외부 사용자가 컴파일 과정을 수동으로 진행할 수 있도록 저수준 API를 제공합니다. 이를 통해 서드파티는 rustc의 내부를 크레이트 분석용 라이브러리로 효과적으로 사용하거나, rustc_driver가 충분히 유연하지 않은 경우(예: rustdoc가 코드를 컴파일하고 출력을 제공하는 경우)에 컴파일러를 임시로 흉내 낼 수 있습니다.
rustc_interface의 주 진입점(rustc_interface::run_compiler)은 컴파일러 구성 변수와, 아직 확정되지 않은 Compiler를 인수로 받는 closure를 받습니다. run_compiler는 구성으로부터 Compiler를 생성하여 closure에 전달합니다. closure 안에서는 Compiler를 사용하여 여러 함수를 호출함으로써 크레이트를 컴파일하고 결과를 얻을 수 있습니다. rustc_interface 사용법의 최소 예제는 여기에서 볼 수 있습니다.
rustc_interface를 사용할 때 필요한 여러 함수의 사용 예는 rustc_driver 구현, 특히 rustc_driver_impl::run_compiler(rustc_interface::run_compiler와 혼동하지 마십시오)를 살펴보면 확인할 수 있습니다.
경고: 컴파일러 내부 API는 그 본질상 언제나 불안정할 수밖에 없습니다. 그렇더라도 불필요하게 기존 기능을 깨뜨리지 않도록 노력하고 있습니다.
외부 rustc_driver
rustc_private
개요
rustc_private 기능을 사용하면 외부 크레이트에서 컴파일러 내부 구현을 사용할 수 있습니다.
공식 툴체인에서 rustc_private 사용하기
rustup으로 배포되는 공식 Rust 툴체인에서 rustc_private 기능을 사용하려면 추가 구성 요소 두 가지를 설치해야 합니다:
rustc-dev: 컴파일러 라이브러리를 제공합니다llvm-tools: 링크에 필요한 LLVM 라이브러리를 제공합니다
설치 단계
rustup으로 두 구성 요소를 모두 설치하십시오:
rustup component add rustc-dev llvm-tools
흔한 오류
llvm-tools 구성 요소가 없으면 다음과 같은 링크 오류가 발생합니다:
error: linking with `cc` failed: exit status: 1
|
= note: rust-lld: error: unable to find library -lLLVM-{version}
사용자 정의 툴체인에서 rustc-private 사용하기
직접 빌드한 툴체인이나 rustup을 사용하지 않는 환경에서는 일반적으로 추가 설정이 필요합니다:
요구 사항
- 시스템의 라이브러리 검색 경로에서 LLVM 라이브러리를 찾을 수 있어야 합니다
- LLVM 버전은 Rust 툴체인을 빌드할 때 사용한 버전과 같아야 합니다
문제 해결 단계
- LLVM이 설치되어 있고 접근할 수 있는지 확인하십시오
- 라이브러리 경로가 설정되어 있는지 확인하십시오:
export LD_LIBRARY_PATH=/path/to/llvm/lib:$LD_LIBRARY_PATH - LLVM 버전이 Rust 툴체인과 호환되는지 확인하십시오
트리 외부 프로젝트를 위한 rust-analyzer 설정하기
rustc_private 크레이트를 사용하는 트리 외부 프로젝트를 개발할 때는 rust-analyzer가 이러한 크레이트를 인식하도록 설정할 수 있습니다.
설정 단계
-
에디터 설정에서
rust-analyzer.rustc.source를"discover"로 설정하십시오.VS Code에서는
rust_analyzer_settings.json에 다음을 추가하십시오:{ "rust-analyzer.rustc.source": "discover" } -
rustc_private를 사용하는 모든 크레이트의Cargo.toml에 다음을 추가하십시오:[package.metadata.rust-analyzer] rustc_private = true
이렇게 설정하면 rust-analyzer가 트리 외부 프로젝트의 rustc_private 크레이트를 올바르게 인식하고 IDE 지원을 제공할 수 있습니다.
rustc_private의 nightly 문서 보기
최신 nightly
최신 nightly의 경우 rustc-docs 구성 요소를 설치한 뒤 브라우저에서 바로 열어 볼 수 있습니다:
rustup component add rustc-docs
rustup doc --rustc-docs
참고:
rustc-docs컴포넌트는 최신 nightly 툴체인에서만 제공되며, 모든 nightly 날짜에 존재하지 않을 수도 있습니다. 이 컴포넌트는 PR #75560(2020년 8월)에서 처음 도입되었습니다.
이전 nightly
이전 nightly의 컴파일러 내부에 의존하고 있다면, 해당 nightly의 내부 문서를 참고하는 것이 좋습니다. 이렇게 하는 유일한 방법은 문서를 로컬에서 생성하는 것입니다. 예를 들어, nightly-2025-11-08의 문서를 얻으려면 다음과 같이 하십시오.
해당 nightly의 Git 커밋 해시를 가져오십시오:
rustup toolchain install nightly-2025-11-08
rustc +nightly-2025-11-08 --version --verbose
출력에는 정확한 소스 리비전을 식별하는 commit-hash 줄이 포함됩니다. 해당 커밋에서 rust-lang/rust를 체크아웃한 다음, 컴파일러 문서의 단계를 따르십시오.
추가 자료
- GitHub 이슈 #137421에서는
rustc_private링커 오류가llvm-tools가 설치되어 있지 않아 발생하는 경우가 많다고 설명합니다.
예시: rustc_driver를 통한 타입 검사
rustc_driver를 사용하면 컴파일의 여러 단계에서 Rust 코드와 상호 작용할 수 있습니다.
표현식의 타입 얻기
표현식의 타입을 얻으려면 after_analysis 콜백을 사용하여 TyCtxt를 얻으십시오.
// Tested with nightly-2025-03-28
#![feature(rustc_private)]
extern crate rustc_ast;
extern crate rustc_ast_pretty;
extern crate rustc_data_structures;
extern crate rustc_driver;
extern crate rustc_error_codes;
extern crate rustc_errors;
extern crate rustc_hash;
extern crate rustc_hir;
extern crate rustc_interface;
extern crate rustc_middle;
extern crate rustc_session;
extern crate rustc_span;
use std::io;
use std::path::Path;
use std::sync::Arc;
use rustc_ast_pretty::pprust::item_to_string;
use rustc_driver::{Compilation, run_compiler};
use rustc_interface::interface::{Compiler, Config};
use rustc_middle::ty::TyCtxt;
struct MyFileLoader;
impl rustc_span::source_map::FileLoader for MyFileLoader {
fn file_exists(&self, path: &Path) -> bool {
path == Path::new("main.rs")
}
fn read_file(&self, path: &Path) -> io::Result<String> {
if path == Path::new("main.rs") {
Ok(r#"
fn main() {
let message = "Hello, World!";
println!("{message}");
}
"#
.to_string())
} else {
Err(io::Error::other("oops"))
}
}
fn read_binary_file(&self, _path: &Path) -> io::Result<Arc<[u8]>> {
Err(io::Error::other("oops"))
}
}
struct MyCallbacks;
impl rustc_driver::Callbacks for MyCallbacks {
fn config(&mut self, config: &mut Config) {
config.file_loader = Some(Box::new(MyFileLoader));
}
fn after_crate_root_parsing(
&mut self,
_compiler: &Compiler,
krate: &mut rustc_ast::Crate,
) -> Compilation {
for item in &krate.items {
println!("{}", item_to_string(&item));
}
Compilation::Continue
}
fn after_analysis(&mut self, _compiler: &Compiler, tcx: TyCtxt<'_>) -> Compilation {
// Iterate over the top-level items in the crate, looking for the main function.
for id in tcx.hir_free_items() {
let item = &tcx.hir_item(id);
// Use pattern-matching to find a specific node inside the main function.
if let rustc_hir::ItemKind::Fn { body, .. } = item.kind {
let expr = &tcx.hir_body(body).value;
if let rustc_hir::ExprKind::Block(block, _) = expr.kind {
if let rustc_hir::StmtKind::Let(let_stmt) = block.stmts[0].kind {
if let Some(expr) = let_stmt.init {
let hir_id = expr.hir_id; // hir_id identifies the string "Hello, world!"
let def_id = item.hir_id().owner.def_id; // def_id identifies the main function
let ty = tcx.typeck(def_id).node_type(hir_id);
println!("{expr:#?}: {ty:?}");
}
}
}
}
}
Compilation::Stop
}
}
fn main() {
run_compiler(
&[
// The first argument, which in practice contains the name of the binary being executed
// (i.e. "rustc") is ignored by rustc.
"ignored".to_string(),
"main.rs".to_string(),
],
&mut MyCallbacks,
);
}
예제: rustc_interface를 통해 진단 얻기
rustc_interface를 사용하면 원래는 stderr로 출력될 진단을 가로챌 수 있습니다.
진단 얻기
컴파일러에서 진단을 얻으려면 진단을 버퍼로 출력하도록 rustc_interface::Config를 구성하고, 각 항목에 대해 TyCtxtEnsureOk::typeck를 실행하십시오.
// Tested with nightly-2025-03-28
#![feature(rustc_private)]
extern crate rustc_data_structures;
extern crate rustc_driver;
extern crate rustc_error_codes;
extern crate rustc_errors;
extern crate rustc_hash;
extern crate rustc_hir;
extern crate rustc_interface;
extern crate rustc_session;
extern crate rustc_span;
use std::sync::{Arc, Mutex};
use rustc_errors::emitter::Emitter;
use rustc_errors::registry::Registry;
use rustc_errors::translation::Translate;
use rustc_errors::{DiagInner, FluentBundle};
use rustc_session::config;
use rustc_span::source_map::SourceMap;
struct DebugEmitter {
source_map: Arc<SourceMap>,
diagnostics: Arc<Mutex<Vec<DiagInner>>>,
}
impl Translate for DebugEmitter {
fn fluent_bundle(&self) -> Option<&FluentBundle> {
None
}
fn fallback_fluent_bundle(&self) -> &FluentBundle {
panic!("this emitter should not translate message")
}
}
impl Emitter for DebugEmitter {
fn emit_diagnostic(&mut self, diag: DiagInner, _: &Registry) {
self.diagnostics.lock().unwrap().push(diag);
}
fn source_map(&self) -> Option<&SourceMap> {
Some(&self.source_map)
}
}
fn main() {
let buffer: Arc<Mutex<Vec<DiagInner>>> = Arc::default();
let diagnostics = buffer.clone();
let config = rustc_interface::Config {
opts: config::Options::default(),
// This program contains a type error.
input: config::Input::Str {
name: rustc_span::FileName::Custom("main.rs".into()),
input: "
fn main() {
let x: &str = 1;
}
"
.into(),
},
crate_cfg: Vec::new(),
crate_check_cfg: Vec::new(),
output_dir: None,
output_file: None,
file_loader: None,
lint_caps: rustc_hash::FxHashMap::default(),
psess_created: Some(Box::new(|parse_sess| {
parse_sess.dcx().set_emitter(Box::new(DebugEmitter {
source_map: parse_sess.clone_source_map(),
diagnostics,
}));
})),
register_lints: None,
override_queries: None,
make_codegen_backend: None,
expanded_args: Vec::new(),
ice_file: None,
track_state: None,
using_internal_features: &rustc_driver::USING_INTERNAL_FEATURES,
};
rustc_interface::run_compiler(config, |compiler| {
let krate = rustc_interface::passes::parse(&compiler.sess);
rustc_interface::create_and_enter_global_ctxt(&compiler, krate, |tcx| {
// Iterate all the items defined and perform type checking.
tcx.par_hir_body_owners(|item_def_id| {
tcx.ensure_ok().typeck(item_def_id);
});
});
// If the compiler has encountered errors when this closure returns, it will abort (!) the program.
// We avoid this by resetting the error count before returning
compiler.sess.dcx().reset_err_count();
});
// Read buffered diagnostics.
buffer.lock().unwrap().iter().for_each(|diagnostic| {
println!("{diagnostic:#?}");
});
}
오류와 린트
rustc가 훌륭한 오류 메시지를 제공하도록 많은 노력을 기울여 왔습니다. 이 장에서는 컴파일러에서 컴파일 오류와 린트를 내보내는 방법을 다룹니다.
진단 구조
진단 오류의 주요 구성 요소는 다음과 같습니다:
error[E0000]: main error message
--> file.rs:LL:CC
|
LL | <code>
| -^^^^- secondary label
| |
| primary label
|
= note: note without a `Span`, created with `.note`
note: sub-diagnostic message for `.span_note`
--> file.rs:LL:CC
|
LL | more code
| ^^^^
- 수준(
error,warning등). 메시지의 심각도를 나타냅니다. (진단 수준을 참고하십시오) - 코드(예를 들어 “mismatched types“의 경우
E0308). 오류 코드 색인에 있는 문제의 확장 설명을 통해 사용자가 현재 오류에 대한 더 많은 정보를 얻을 수 있도록 돕습니다. 모든 진단에 코드가 있는 것은 아닙니다. 예를 들어 린트가 생성한 진단에는 코드가 없습니다. - 메시지. 문제에 대한 주된 설명입니다. 메시지는 따로 떼어 놓고 보아도 의미가 통하도록 일반적이어야 하며 그 자체로 완결되어야 합니다.
- 진단 창. 진단 창에는 여러 요소가 포함됩니다:
- 주 스팬이 시작되는 위치의 경로, 줄 번호, 열 번호.
- 영향을 받는 사용자 코드와 그 주변 코드.
- 사용자 코드 아래에 표시되는 주 스팬과 보조 스팬. 이 스팬들은 선택적으로 하나 이상의 레이블을 포함할 수 있습니다.
- 주 스팬에는 그것만 표시되는 경우(예를 들어 IDE에서)에도 의미가 통하도록 문제를 설명하기에 충분한 텍스트가 있어야 합니다. 주 스팬은 “공간을 인식“하므로(코드를 가리키므로) 일반적으로 오류 메시지보다 더 간결하게 쓸 수 있습니다.
- 여러 스팬 레이블이 겹쳐 출력이 어수선해질 것으로 예상된다면 출력을 적절히 조정하는 것이 좋습니다. 예를 들어
if/else arms have incompatible types오류는 모든 갈래가 같은 줄에 있는지, 갈래 중 하나가 비어 있는지, 또는 두 경우 모두 해당하지 않는지에 따라 서로 다른 스팬을 사용합니다.
- 하위 진단. 모든 오류는 오류의 주요 부분과 비슷해 보이는 하위 진단을 여러 개 가질 수 있습니다. 하위 진단은 설명의 순서가 코드의 순서와 일치하지 않을 수 있는 경우에 사용됩니다. 설명의 순서에 제약이 없다면 주 진단에서 보조 레이블을 활용하는 편이 좋습니다. 일반적으로 덜 장황하기 때문입니다.
텍스트는 사실만 담백하게 서술해야 하며, 여러 문장이 필요한 경우가 아니라면 대문자와 마침표를 쓰지 않아야 합니다:
error: the fobrulator needs to be krontrificated
메시지나 레이블에 코드나 식별자를 넣어야 할 때는 백틱으로 감싸야 합니다:
error: the identifier `foo.bar` is invalid
오류 코드와 설명
대부분의 오류에는 관련된 오류 코드가 있습니다. 오류 코드는 긴 형식의 설명과 연결되어 있으며, 이 설명에는 해당 오류를 발생시키는 방법에 대한 예제와 오류에 대한 상세한 내용이 담겨 있습니다. 이 설명은 --explain 플래그나 오류 색인을 통해 볼 수 있습니다.
일반적으로, 설명이 오류 자체보다 더 많은 정보를 제공한다면 오류에 (관련 설명과 함께) 코드를 부여하십시오. 대부분의 경우에는 모든 정보를 출력되는 오류 자체에 담는 것이 더 좋습니다. 하지만 그렇게 하면 오류가 장황해지거나, 발생 원인이 너무 많아서 모든 경우에 유용한 정보를 오류에 담을 수 없는 때도 있으며, 이런 경우에는 설명을 추가하는 것이 좋습니다.1 늘 그렇듯이, 확실하지 않다면 리뷰어에게 물어보십시오!
관련 오류 코드가 있는 새 오류를 추가하기로 했다면, 그 절차에 대한 안내와 중요한 세부 사항은 이 절을 읽어 보십시오.
린트와 고정 진단
일부 메시지는 린트를 통해 출력되며, 이때 사용자가 수준을 조절할 수 있습니다. 대부분의 진단은 사용자가 수준을 조절할 수 없도록 하드코딩되어 있습니다.
보통은 진단을 “고정“으로 할지 린트로 할지가 분명하지만, 애매한 영역도 있습니다.
다음은 몇 가지 예입니다.
- 빌림 검사기 오류: 이는 고정 오류입니다. 사용자는 빌림 검사기를 잠재우기 위해 이러한 진단의 수준을 조정할 수 없습니다.
- 죽은 코드: 이는 린트입니다. 사용자가 자신의 크레이트에 죽은 코드가 있기를 바라지는 않겠지만, 이를 하드 오류로 만들면 리팩터링과 개발이 매우 고통스러워집니다.
- 향후 비호환 린트: 이는 잠재울 수 있는 린트입니다. 이를 고정 오류로 만들면 너무 많은 코드가 깨질 것이라고 판단했기 때문에, 대신 경고를 출력하며 이 경고는 결국 고정(하드) 오류로 바뀔 것입니다.
일반적인 코드에서는 하드코딩된 경고(span_warn 같은 메서드를 사용하는 경고)를 피하고, 대신 린트를 사용하는 것이 좋습니다. CLI 플래그 관련 경고와 같은 일부 경우에는 하드코딩된 경고를 사용해야 합니다.
고정 오류 대신 오류 수준의 린트를 사용해야 하는 경우에 대한 지침은 아래의 deny 린트 수준을 참고하십시오.
진단 출력 스타일 가이드
- 평이하고 간단한 영어로 작성하십시오. 메시지를 (한동안 닦지 않은) 작을 수도 있는 화면에 띄웠을 때, 밤새 파티를 하고 막 잠자리에서 일어난 평범한 프로그래머가 이해할 수 없다면 그 메시지는 너무 복잡한 것입니다.
Error,Warning,Note,Help메시지는 소문자로 시작하며 문장 부호로 끝나지 않습니다.- 오류 메시지는 간결해야 합니다. 사용자는 이러한 오류 메시지를 여러 번 보게 되며, 더 자세한 설명은
--explain플래그로 볼 수 있습니다. 그렇다고 해서 이해하기 어려울 정도로 짧게 만들지는 마십시오. - “illegal”(불법)이라는 단어는 불법입니다. 대신 “invalid”(유효하지 않은)나 더 구체적인 단어를 사용하십시오.
- 오류는 발생한 코드의 스팬을 기록해야 합니다(
rustc_errors::DiagCtxt의span_*메서드나 진단 구조체의#[primary_span]을 사용하면 쉽게 할 수 있습니다). 또한 스팬이 너무 크지 않다면 오류에 기여한 다른 스팬도note로 표시하십시오. - 스팬과 함께 메시지를 내보낼 때는 문제를 여전히 나타낼 수 있는 범위에서 스팬을 가능한 한 작게 줄이도록 하십시오
- 같은 오류에 대해 여러 개의 오류 메시지를 내보내지 않도록 하십시오. 이를 위해 중복을 감지해야 할 수도 있습니다.
- 컴파일러가 특정 오류 메시지를 만들기에 정보가 너무 부족할 때는, 더 많은 정보를 추가할 수 있도록 라이브러리 코드용 새 속성을 추가하는 것에 대해 컴파일러 팀과 상의하십시오. 예를 들어
#[rustc_on_unimplemented]를 참고하십시오. 이러한 어노테이션을 사용할 수 있다면 사용하십시오! - Rust의 학습 곡선은 꽤 가파르며, 컴파일러 메시지가 중요한 학습 도구라는 점을 명심하십시오.
- 컴파일러에 대해 이야기할 때는
Rust나rustc가 아니라the compiler라고 부르십시오. - 항목을 나열할 때는 옥스퍼드 쉼표를 사용하십시오.
- 속성을 언급할 때는 가능하면 “the
inlineattribute“와 같은 형식을 사용하십시오. 관련이 있는 경우가 아니라면 메시지나 스팬에#[/#![/]구분자나 속성 인자를 포함하지 마십시오.
린트 이름 짓기
RFC 0344에 따르면, 린트 이름은 다음 지침에 따라 일관되게 지어야 합니다.
기본 규칙은 다음과 같습니다. 린트 이름은 “allow lint-name” 또는 “allow lint-name items“로 읽었을 때 자연스러워야 합니다. 예를 들어 “allow deprecated items“와 “allow dead_code“는 자연스럽지만, “allow unsafe_block“은 문법에 맞지 않습니다(복수형이어야 합니다).
-
린트 이름은
#[allow(deprecated)](items)가 올바르게 읽히도록,deprecated처럼 검사 대상이 되는 나쁜 점을 나타내야 합니다. 따라서ctypes는 적절한 이름이 아니며,improper_ctypes가 적절합니다. -
(안정성 린트처럼) 임의의 항목에 적용되는 린트는 무엇을 검사하는지만 언급해야 합니다.
deprecated_items보다는deprecated를 사용하십시오. 이렇게 하면 린트 이름이 짧게 유지됩니다. (다시 말하지만, “allow lint-name items“를 떠올리십시오.) -
린트가 특정 문법적 범주에 적용된다면 그 범주를 언급하고 복수형을 사용하십시오.
unused_variable보다는unused_variables를 사용하십시오. 이렇게 하면#[allow(unused_variables)]가 올바르게 읽힙니다. -
코드의 불필요하거나, 사용되지 않거나, 쓸모없는 부분을 잡아내는 린트는
unused_imports,unused_typecasts처럼unused라는 용어를 사용해야 합니다. -
함수 이름을 지을 때와 같은 방식으로 스네이크 케이스를 사용하십시오.
진단 수준
각 진단 수준에 대한 지침은 다음과 같습니다.
-
error: 프로그램이 유효하지 않거나 프로그래머가 특정warning을 오류로 만들기로 하여, 컴파일러가 프로그램을 컴파일할 수 없는 문제를 감지했을 때 내보냅니다. -
warning: 컴파일러가 프로그램에서 이상한 점을 감지했을 때 내보냅니다. 경고를 추가할 때는 경고 피로를 피하고, 실제로는 코드에 문제가 없는데 경고하는 거짓 양성을 피하도록 주의해야 합니다. 경고를 내보내는 것이 적절한 경우의 몇 가지 예는 다음과 같습니다.- 사용 중단된 항목을 교체하거나
Result를 사용하는 것처럼 사용자가 조치를 취해야 하지만, 그 외에는 컴파일을 막지 않는 상황입니다. - 코드의 의미에 영향을 주지 않고 제거할 수 있는 불필요한 구문입니다. 예를 들어 사용되지 않는 코드나 불필요한
unsafe가 있습니다. - 잘못되었거나 위험하거나 혼란스러울 가능성이 매우 높지만 언어가 기술적으로 허용하며, 아직 오류로 만들 만큼 준비되지 않았거나 확신이 없는 코드입니다.
unused_comparisons(범위를 벗어난 비교)나bindings_with_variant_name(사용자가 패턴에서 바인딩을 만들려는 의도가 아니었을 가능성이 높음)이 그 예입니다. - 과거에 실수로 또는 잘못 허용되었지만, 이를 거부하면 생태계에 과도한 파손이 발생하는 향후 비호환 린트입니다.
- 스타일상의 선택입니다. 예를 들어 카멜 케이스나 스네이크 케이스, 또는 2018 에디션의
dyn트레이트 경고가 있습니다. 이러한 린트는 추가되기 위한 기준이 높으며, 예외적인 상황에서만 사용해야 합니다. 그 밖의 스타일상의 선택은 기본적으로 허용(allow-by-default)되는 린트로 만들거나, Clippy나 rustfmt 같은 다른 도구의 일부가 되어야 합니다.
- 사용 중단된 항목을 교체하거나
-
help:error나warning뒤에 출력되어 사용자에게 문제를 해결하는 방법에 대한 추가 정보를 제공합니다. 이러한 메시지에는 도구가 소스를 자동으로 수정할 수 있도록 안내하는 제안 문자열과rustc_errors::Applicability신뢰 수준이 포함되는 경우가 많습니다. 자세한 내용은 제안 절을 참고하십시오.오류나 경고 부분에서는 문제를 해결하는 방법을 제안하지 않아야 하며, “help” 하위 진단에서만 제안해야 합니다.
-
note: 더 많은 맥락을 제공하고, 경고나 오류를 일으킨 추가적인 상황과 코드 부분을 알려 주기 위해 출력됩니다. 예를 들어 빌림 검사기는 이전에 있었던 충돌하는 빌림을 모두 note로 알려 줍니다.help와note의 차이:help는 사용자가 문제를 해결하기 위해 할 수 있는 변경 사항을 보여 주는 데 사용해야 합니다.note는 다른 맥락, 정보와 사실, 읽어 볼 온라인 자료 등 그 밖의 모든 것에 사용해야 합니다.
린트 수준과 혼동하지 마십시오. 린트 수준의 지침은 다음과 같습니다.
-
forbid: 린트의 기본값은 절대forbid가 되어서는 안 됩니다. -
deny:error진단 수준과 동일합니다. 몇 가지 예는 다음과 같습니다.- 경고 수준에서 격상된 향후 비호환 린트 또는 에디션 기반 린트입니다.
- 잘못되었다는 확신이 극도로 높지만, 그래도 통과시킬 수 있는 탈출구가 필요한 경우입니다.
-
warn:warning진단 수준과 동일합니다. 지침은 위의warning을 참고하십시오. -
allow: 기본값이allow여야 하는 린트의 종류에 대한 예는 다음과 같습니다.- 린트의 거짓 양성 비율이 너무 높은 경우입니다.
- 린트가 지나치게 주관적인 경우입니다.
- 린트가 실험적인 경우입니다.
- 린트가 일반적으로 강제되지 않는 것을 강제하는 데 사용되는 경우입니다. 예를 들어
unsafe_code린트는 unsafe 코드의 사용을 막는 데 사용할 수 있습니다.
린트 수준에 대한 자세한 정보는 rustc 책과 레퍼런스에서 찾을 수 있습니다.
유용한 팁과 옵션
오류의 원인 찾기
특정 오류가 어디에서 방출되는지 찾는 주요 방법은 세 가지입니다:
-
오류 메시지/레이블의 일부나 오류 코드로
grep을 하십시오. 이 방법은 대개 잘 동작하고 간단하지만, 오류를 방출하는 코드가 오류를 구성하는 코드로부터 비교적 깊은 호출 스택 너머로 떨어져 있는 경우도 있습니다. 그런 경우에도 이 방법은 방향을 잡는 데 좋은 수단입니다. -
nightly 전용 플래그
-Z treat-err-as-bug=1을 붙여rustc를 호출하면 처음 방출되는 오류를 내부 컴파일러 오류(Internal Compiler Error)로 취급하므로, 오류가 방출된 지점의 스택 트레이스를 얻을 수 있습니다. 더 나중의 오류에서 트리거하고 싶다면1을 다른 값으로 바꾸십시오.이 방법에는 다음과 같은 한계가 있습니다:
- 일부 호출은 컴파일된
rustc에서 인라인되기 때문에 스택 트레이스에서 생략됩니다. - 오류가 구성되는 곳이 방출되는 곳과 멀리 떨어져 있어,
grep방식에서 겪었던 것과 비슷한 문제가 생깁니다. 어떤 경우에는 여러 오류를 순서대로 방출하기 위해 버퍼에 모아 둡니다.
- 일부 호출은 컴파일된
-
-Z track-diagnostics를 붙여rustc를 호출하면 오류와 함께 오류가 생성된 위치를 출력합니다.
일반적인 개발 관행도 적용됩니다: 일이 어떤 순서로 일어나는지 파악하기 위해 debug!() 문을 적절히 사용하고, 디버거로 중단점을 걸어 보십시오.
Span
Span은 rustc에서 컴파일 중인 코드의 위치를 나타내는 데 쓰이는 주요 자료 구조입니다. Span은 HIR과 MIR의 대부분의 구성 요소에 붙어 있어, 더 유익한 오류 보고를 가능하게 합니다.
Span을 SourceMap에서 조회하면, span_to_snippet 및 SourceMap의 다른 유사한 메서드를 사용하여 오류를 표시하는 데 유용한 “스니펫“을 얻을 수 있습니다.
오류 메시지
rustc_errors 크레이트는 오류 보고에 사용되는 유틸리티 대부분을 정의합니다.
진단은 Diagnostic 트레이트를 구현하는 타입으로 구현할 수 있습니다. 이 방식은 진단을 방출하는 로직과 주요 코드 경로를 분리하도록 강제하므로 새 진단에 권장됩니다. 덜 복잡한 진단의 경우 Diagnostic 트레이트를 derive할 수 있습니다 – 진단 구조체를 참고하십시오. 트레이트 구현 안에서는 아래에 설명된 API를 평소처럼 사용할 수 있습니다.
DiagCtxt에는 오류를 생성하고 방출하는 메서드가 있습니다. 이 메서드들은 보통 span_err, struct_span_err, span_warn 등과 같은 이름을 가집니다. 이런 메서드는 매우 많으며, 경고, 오류, 치명적 오류, 제안 등 다양한 종류의 “오류“를 방출합니다.
일반적으로 이러한 메서드는 두 부류로 나뉩니다: 오류를 바로 방출하는 메서드와, 무엇을 방출할지 더 세밀하게 제어할 수 있게 해 주는 메서드입니다. 예를 들어 span_err는 주어진 Span에 주어진 오류 메시지를 방출하지만, struct_span_err는 대신 Diag를 반환합니다.
이 메서드들 대부분은 문자열을 받지만, 새 진단에는 번역 가능한 진단을 위한 타입이 지정된 식별자를 사용하는 것이 권장됩니다(번역 참고).
Diag를 사용하면 emit 메서드를 호출해 오류를 방출하기 전에 관련 노트와 제안을 오류에 추가할 수 있습니다. (Diag를 방출하지도 취소하지도 않으면 ICE가 발생합니다.) 할 수 있는 작업에 대한 자세한 내용은 문서를 참고하십시오.
// Get a `Diag`. This does _not_ emit an error yet.
let mut err = sess.dcx.struct_span_err(sp, fluent::example::example_error);
// In some cases, you might need to check if `sp` is generated by a macro to
// avoid printing weird errors about macro-generated code.
if let Ok(snippet) = sess.source_map().span_to_snippet(sp) {
// Use the snippet to generate a suggested fix
err.span_suggestion(suggestion_sp, fluent::example::try_qux_suggestion, format!("qux {}", snippet));
} else {
// If we weren't able to generate a snippet, then emit a "help" message
// instead of a concrete "suggestion". In practice this is unlikely to be
// reached.
err.span_help(suggestion_sp, fluent::example::qux_suggestion);
}
// emit the error
err.emit();
example-example-error = oh no! this is an error!
.try-qux-suggestion = try using a qux here
.qux-suggestion = you could use a qux here instead
제안
사용자의 코드가 왜 잘못되었는지 정확히 알려 주는 것에 더해, 이를 어떻게 고칠 수 있는지까지 알려 줄 수 있는 경우도 많습니다. 이를 위해 Diag는 구조화된 제안 API를 제공합니다. 이 API는 코드 제안을 터미널에 보기 좋게 형식화하거나, (--error-format json 플래그가 전달된 경우) rustfix 같은 도구가 사용할 수 있도록 JSON으로 형식화합니다.
모든 제안을 기계적으로 적용해야 하는 것은 아닙니다. 제안된 코드에는 신뢰도가 있으며, 이는 높음(Applicability::MachineApplicable)부터 낮음(Applicability::MaybeIncorrect)까지 다양합니다. 수준을 선택할 때는 보수적으로 판단하십시오. 제안을 만들려면 Diag의 span_suggestion 메서드를 사용하십시오. 마지막 인자는 제안을 기계적으로 적용할 수 있는지 여부에 대한 힌트를 도구에 제공합니다.
제안은 하나 이상의 스팬을 가리키며, 각 스팬에는 현재 내용을 대체할 코드가 대응됩니다.
제안과 함께 표시되는 메시지는 다음과 같은 맥락에서 이해할 수 있어야 합니다.
- 독립적인 하위 진단으로 표시되는 경우(기본 출력입니다)
- 영향을 받는 스팬을 가리키는 레이블로 표시되는 경우(장황함에 관한 몇 가지 휴리스틱을 충족하면 자동으로 이렇게 표시됩니다)
- 내용이 없는
help하위 진단으로 표시되는 경우(텍스트만으로도 제안이 명확하지만 여전히 도구가 제안을 적용할 수 있게 하려는 경우에 사용합니다) - 표시되지 않는 경우(매우 명확하지만 여전히 도구가 제안을 적용할 수 있게 하려는 경우에 사용합니다)
예를 들어 qux 제안을 기계적으로 적용할 수 있게 만들려면 다음과 같이 합니다.
let mut err = sess.dcx.struct_span_err(sp, fluent::example::message);
if let Ok(snippet) = sess.source_map().span_to_snippet(sp) {
err.span_suggestion(
suggestion_sp,
fluent::example::try_qux_suggestion,
format!("qux {}", snippet),
Applicability::MachineApplicable,
);
} else {
err.span_help(suggestion_sp, fluent::example::qux_suggestion);
}
err.emit();
이 코드는 다음과 같은 오류를 출력할 수 있습니다.
$ rustc mycode.rs
error[E0999]: oh no! this is an error!
--> mycode.rs:3:5
|
3 | sad()
| ^ help: try using a qux here: `qux sad()`
error: aborting due to previous error
For more information about this error, try `rustc --explain E0999`.
제안이 여러 줄에 걸쳐 있거나 제안이 여러 개인 경우처럼, 어떤 경우에는 제안이 따로 표시됩니다.
error[E0999]: oh no! this is an error!
--> mycode.rs:3:5
|
3 | sad()
| ^
help: try using a qux here:
|
3 | qux sad()
| ^^^
error: aborting due to previous error
For more information about this error, try `rustc --explain E0999`.
Applicability에 사용할 수 있는 값은 다음과 같습니다.
MachineApplicable: 기계적으로 적용할 수 있습니다.HasPlaceholders: 제안에 자리 표시자 텍스트가 포함되어 있으므로 기계적으로 적용할 수 없습니다. 예:try adding a type: `let x: <type>`.MaybeIncorrect: 좋은 제안인지 아닌지 알 수 없으므로 기계적으로 적용할 수 없습니다.Unspecified: 위의 경우 중 어디에 해당하는지 알 수 없으므로 기계적으로 적용할 수 없습니다.
제안 스타일 가이드
-
제안은 질문 형태여서는 안 됩니다. 특히 “did you mean“과 같은 표현은 피해야 합니다. 때로는 특정 제안이 왜 나왔는지 분명하지 않을 수 있습니다. 이러한 경우에는 제안이 무엇인지 솔직하게 밝히는 것이 좋습니다.
“did you mean:
Foo“와 “there is a struct with a similar name:Foo“를 비교해 보십시오. -
메시지에는 “the following”, “as shown“과 같은 문구를 포함해서는 안 됩니다. 무엇에 관한 이야기인지는 스팬을 사용하여 전달하십시오.
-
메시지에는 “to do xyz, use“나 “to do xyz, use abc“와 같은 추가 안내를 포함할 수 있습니다.
-
메시지에는 함수, 변수 또는 타입의 이름을 포함할 수 있지만, 표현식 전체를 포함하는 것은 피하십시오.
린트
컴파일러의 린트 인프라는 rustc_middle::lint 모듈에 정의되어 있습니다.
린트는 언제 실행됩니까?
린트마다 작업에 필요한 정보가 다르므로, 린트는 저마다 다른 시점에 실행됩니다. 일부 린트는 패스로 묶이며, 한 패스에 속한 린트들은 하나의 방문자(visitor)를 통해 함께 처리됩니다. 패스에는 다음과 같은 것들이 있습니다.
-
확장 전 패스: 매크로 확장 전에 AST 노드를 대상으로 동작합니다. 일반적으로 이 패스는 사용하지 않는 것이 좋습니다.
- 예시:
keyword_idents는 향후 에디션에서 키워드가 될 식별자를 검사하지만, 매크로에서 사용된 식별자에 민감합니다.
- 예시:
-
초기 린트 패스: 매크로 확장과 이름 해석 이후, AST 로워링 직전에 AST 노드를 대상으로 동작합니다. 이 린트들은 순수하게 구문적인 린트를 위한 것입니다.
- 예시:
unused_parens린트는if조건처럼 괄호가 필요하지 않은 상황에서 괄호로 묶인 표현식을 검사합니다.
- 예시:
-
후기 린트 패스: 분석의 끝 무렵(빌림 검사 이후 등)에 HIR 노드를 대상으로 동작합니다. 이 린트들은 완전한 타입 정보를 사용할 수 있습니다. 대부분의 린트는 후기 린트입니다.
- 예시:
invalid_value린트(명백히 유효하지 않은 초기화되지 않은 값을 검사함)는 어떤 타입이 초기화되지 않은 상태로 남아도 되는지 판단하는 데 타입 정보가 필요하므로 후기 린트입니다.
- 예시:
-
MIR 패스: MIR 노드를 대상으로 동작합니다. 이 패스는 다른 패스와 완전히 같지는 않습니다. MIR 노드를 대상으로 하는 린트에는 고유한 실행 방법이 있습니다.
- 예시:
arithmetic_overflow린트는 오버플로가 발생할 수 있는 상수 값을 감지하면 발생합니다.
- 예시:
대부분의 린트는 패스 시스템을 통해 잘 동작하며, 인터페이스가 꽤 간단하고 통합하기도 쉽습니다(대부분 특정 check 함수를 구현하기만 하면 됩니다). 하지만 일부 린트는 컴파일러 내 특정 코드 경로에 두면 더 작성하기 쉽습니다. 예를 들어, unused_mut 린트는 빌림 검사기의 일부 정보와 상태가 필요하므로 빌림 검사기 안에 구현되어 있습니다.
이러한 인라인 린트 중 일부는 린트 시스템이 준비되기 전에 발생합니다. 이러한 린트는 버퍼링되어, 린트 시스템이 준비되는 컴파일러의 이후 단계까지 보관됩니다. 컴파일러 초기 단계에서의 린트를 참조하십시오.
린트 정의 용어
린트는 LintStore를 통해 관리되며 다양한 방식으로 등록됩니다. 다음 용어들은 주로 등록 방식에 따라 구분되는 린트의 여러 종류를 가리킵니다.
- 내장 린트는 컴파일러 소스 안에 정의되어 있습니다.
- 드라이버 등록 린트는 외부 드라이버가 컴파일러 드라이버를 생성할 때 등록됩니다. 예를 들어, Clippy가 이 메커니즘을 사용합니다.
- 도구 린트는
clippy::나rustdoc::같은 경로 접두사가 붙은 린트입니다. - 내부 린트는
rustc::범위의 도구 린트로, rustc 소스 트리 자체에서만 실행되며 일반 내장 린트처럼 컴파일러 소스 안에 정의되어 있습니다.
린트 등록에 관한 자세한 내용은 LintStore 장에서 확인할 수 있습니다.
린트 선언하기
내장 컴파일러 린트는 rustc_lint 크레이트에 정의되어 있습니다. 다른 크레이트에서 구현해야 하는 린트는 rustc_lint_defs에 정의되어 있습니다. 가능하면 린트를 rustc_lint에 두는 것이 좋습니다. 이 크레이트는 의존성 루트에 가깝기 때문에 훨씬 빠르게 작업할 수 있다는 장점이 있습니다.
모든 린트는 LintPass trait를 구현하는 struct를 통해 구현됩니다(린트가 실행되기에 가장 적절한 시점에 따라 더 구체적인 린트 패스 트레이트인 EarlyLintPass나 LateLintPass 중 하나를 구현할 수도 있습니다). 이 트레이트 구현을 통해 린터가 AST를 순회하는 동안 특정 구문 구조를 검사할 수 있습니다. 그런 다음 컴파일 오류와 매우 비슷한 방식으로 린트를 내보낼 수 있습니다.
또한 declare_lint! 매크로를 통해 특정 린트의 메타데이터를 선언합니다. 이 매크로에는 이름, 기본 수준, 짧은 설명, 그리고 몇 가지 추가 세부 정보가 포함됩니다.
린트와 린트 패스는 반드시 컴파일러에 등록해야 한다는 점에 유의하십시오.
예를 들어, 다음 린트는 while true { ... }의 사용을 검사하고 대신 loop { ... }를 사용하도록 제안합니다.
// Declare a lint called `WHILE_TRUE`
declare_lint! {
WHILE_TRUE,
// warn-by-default
Warn,
// This string is the lint description
"suggest using `loop { }` instead of `while true { }`"
}
// This declares a struct and a lint pass, providing a list of associated lints. The
// compiler currently doesn't use the associated lints directly (e.g., to not
// run the pass or otherwise check that the pass emits the appropriate set of
// lints). However, it's good to be accurate here as it's possible that we're
// going to register the lints via the get_lints method on our lint pass (that
// this macro generates).
declare_lint_pass!(WhileTrue => [WHILE_TRUE]);
// Helper function for `WhileTrue` lint.
// Traverse through any amount of parenthesis and return the first non-parens expression.
fn pierce_parens(mut expr: &ast::Expr) -> &ast::Expr {
while let ast::ExprKind::Paren(sub) = &expr.kind {
expr = sub;
}
expr
}
// `EarlyLintPass` has lots of methods. We only override the definition of
// `check_expr` for this lint because that's all we need, but you could
// override other methods for your own lint. See the rustc docs for a full
// list of methods.
impl EarlyLintPass for WhileTrue {
fn check_expr(&mut self, cx: &EarlyContext<'_>, e: &ast::Expr) {
if let ast::ExprKind::While(cond, ..) = &e.kind
&& let ast::ExprKind::Lit(ref lit) = pierce_parens(cond).kind
&& let ast::LitKind::Bool(true) = lit.kind
&& !lit.span.from_expansion()
{
let condition_span = cx.sess.source_map().guess_head_span(e.span);
cx.struct_span_lint(WHILE_TRUE, condition_span, |lint| {
lint.build(fluent::example::use_loop)
.span_suggestion_short(
condition_span,
fluent::example::suggestion,
"loop".to_owned(),
Applicability::MachineApplicable,
)
.emit();
})
}
}
}
example-use-loop = denote infinite loops with `loop {"{"} ... {"}"}`
.suggestion = use `loop`
에디션에 따라 달라지는 린트
때로는 새 에디션에서 린트의 동작을 바꾸고 싶을 때가 있습니다. 이렇게 하려면 declare_lint! 호출에 전환 정보를 추가하기만 하면 됩니다:
declare_lint! {
pub ANONYMOUS_PARAMETERS,
Allow,
"detects anonymous parameters",
Edition::Edition2018 => Warn,
}
이렇게 하면 ANONYMOUS_PARAMETERS 린트가 2015 에디션에서는 기본적으로 허용(allow)되지만 2018 에디션에서는 기본적으로 경고(warn)를 냅니다.
자세한 내용은 에디션별 린트를 참고하십시오.
기능 게이트가 적용된 린트
특정 기능에 속한 린트는 크레이트에서 해당 기능이 활성화된 경우에만 사용할 수 있어야 합니다. 이를 지원하기 위해 린트 선언에 다음과 같이 기능 게이트를 포함할 수 있습니다:
declare_lint! {
pub SOME_LINT_NAME,
Warn,
"a new and useful, but feature gated lint",
@feature_gate = sym::feature_name;
}
향후 비호환 린트
컴파일러 내부에서 사용하는 future-incompatible이라는 용어는 rustc가 컴파일러 사용자에게 드러내는 의미보다 약간 더 넓은 의미를 가집니다.
rustc 내부에서 향후 비호환 린트는 사용자가 작성한 코드가 앞으로 컴파일되지 않을 수 있음을 사용자에게 알리기 위한 것입니다. 일반적으로 향후 비호환 코드가 생기는 이유는 두 가지입니다:
- 사용자가 건전하지 않은(unsound) 코드를 작성했는데 컴파일러가 이를 실수로 받아들인 경우입니다. 건전성 구멍을 고치는 것(그 결과 사용자의 코드가 깨지는 것)은 Rust의 하위 호환성 보장 범위 안에 있지만, 이 린트는 코드가 어떤 에디션을 사용하든 상관없이 향후 rustc 버전에서 이런 일이 일어날 것임을 사용자에게 경고하기 위해 존재합니다. rustc가 사용자에게 “향후 비호환(future incompatible)“이라는 이름으로 드러내는 의미는 오직 이것뿐입니다.
- 사용자가 작성한 코드가 다가올 에디션에서 컴파일러로 더 이상 컴파일되지 않거나 의미가 바뀌는 경우입니다. 이러한 린트는 흔히 “에디션 린트“라고 불리며, 일반적으로 사용자가 크레이트의 에디션을 업데이트하면 깨질 코드를 대상으로 린트를 수행하는 여러 “에디션 호환성” 린트 그룹(예:
rust_2021_compatibility)에서 볼 수 있습니다. 자세한 내용은 마이그레이션 린트를 참고하십시오.
향후 비호환 린트는 @future_incompatible이라는 추가 “필드“와 함께 선언해야 합니다:
declare_lint! {
pub ANONYMOUS_PARAMETERS,
Allow,
"detects anonymous parameters",
@future_incompatible = FutureIncompatibleInfo {
reason: fcw!(EditionError 2018 "slug-of-edition-guide-page")
};
}
향후 호환되지 않게 될 변경이 왜 일어나는지를 설명하는 reason 필드에 주목하십시오. 이 필드는 사용자가 받는 진단 메시지를 바꿀 뿐만 아니라 해당 린트가 어떤 린트 그룹에 추가될지도 결정합니다. 위의 예시에서 이 린트는 “에디션 린트“이며(“reason“이 EditionError이므로), 익명 매개변수를 사용한 코드가 Rust 2018 이후로는 더 이상 컴파일되지 않는다는 것을 사용자에게 알려 줍니다.
LintStore::register_lints 내부에서 future_incompatible 필드가 있는 린트는 에디션 기반 린트 그룹(reason이 에디션과 연관된 경우)이나 future_incompatibility 린트 그룹 중 하나에 배치됩니다.
declare_lint! 매크로가 지원하지 않는 옵션 조합이 필요하다면 언제든지 declare_lint! 매크로를 수정하여 이를 지원하도록 할 수 있습니다.
린트 이름 변경 또는 제거
린트의 이름이 부적절하거나 린트가 더 이상 필요하지 않다고 판단되면 해당 린트를 이름 변경 또는 제거 대상으로 등록해야 하며, 이렇게 하면 사용자가 이전 린트 이름을 사용하려 할 때 경고가 발생합니다. 이름 변경이나 제거를 선언하려면 rustc_lint::register_builtins 함수의 코드에 store.register_renamed 또는 store.register_removed를 사용하는 줄을 추가하십시오.
store.register_renamed("single_use_lifetime", "single_use_lifetimes");
린트 그룹
린트는 그룹 단위로 활성화할 수 있습니다. 이러한 그룹은 rustc_lint::lib의 register_builtins 함수에 선언되어 있습니다. 새 그룹을 선언할 때는 add_lint_group! 매크로를 사용합니다.
예를 들면 다음과 같습니다.
add_lint_group!(sess,
"nonstandard_style",
NON_CAMEL_CASE_TYPES,
NON_SNAKE_CASE,
NON_UPPER_CASE_GLOBALS);
이 코드는 나열된 린트를 활성화하는 nonstandard_style 그룹을 정의합니다. 사용자는 소스 코드에 #![warn(nonstandard_style)] 속성을 작성하거나 명령줄에 -W nonstandard-style을 전달하여 이 린트들을 활성화할 수 있습니다.
일부 린트 그룹은 LintStore::register_lints에서 자동으로 생성됩니다. 예를 들어 FutureIncompatibleInfo와 함께 선언되고 그 이유가 FutureIncompatibilityReason::FutureReleaseError(declare_lint!에서 @future_incompatible을 사용할 때의 기본값)인 린트는 모두 future_incompatible 린트 그룹에 추가됩니다. 에디션에도 각자의 린트 그룹(예: rust_2021_compatibility)이 있으며, 이 그룹은 해당 에디션에서 깨지게 될 향후 비호환 코드를 알리는 모든 린트에 대해 자동으로 생성됩니다.
컴파일러 초기 단계에서의 린트
때로는 린트 시스템이 초기화되기 전(예: 파싱이나 매크로 확장 중)에 실행되는 린트를 정의해야 할 수도 있습니다. 경고를 낼지, 오류를 낼지, 아무것도 내지 않을지 알려면 린트 수준이 미리 계산되어 있어야 하므로 이는 문제가 됩니다.
이 문제를 해결하기 위해 린트 시스템이 처리될 때까지 린트를 버퍼에 담아 둡니다. Session과 ParseSess에는 모두 린트를 나중을 위해 버퍼에 담아 둘 수 있는 buffer_lint 메서드가 있습니다. 버퍼에 담긴 린트는 나중에 린트 시스템이 자동으로 처리합니다.
따라서 컴파일 초기에 실행되는 린트를 정의하려면 평소처럼 린트를 정의하되 buffer_lint로 린트를 호출합니다.
컴파일러의 더 이른 단계에서의 린트
파서(rustc_ast)는 다른 rustc* 크레이트 중 어느 것에도 의존성을 가질 수 없다는 점에서 흥미롭습니다. 특히 컴파일러의 모든 린트 인프라가 정의된 rustc_middle::lint나 rustc_lint에 의존할 수 없습니다. 이는 곤란한 일입니다!
이를 해결하기 위해 rustc_ast는 자체적인 버퍼 린트 타입을 정의하며, ParseSess::buffer_lint가 이 타입을 사용합니다. 매크로 확장이 끝나면 이렇게 버퍼에 담긴 린트는 컴파일러의 나머지 부분에서 사용하는 Session::buffered_lints로 옮겨집니다.
JSON 진단 출력
컴파일러는 진단 정보를 JSON 객체로 출력하는 --error-format json 플래그를 지원합니다(cargo fix 같은 도구에서 활용하기 위한 것입니다). 출력은 다음과 같습니다:
$ rustc json_error_demo.rs --error-format json
{"message":"cannot add `&str` to `{integer}`","code":{"code":"E0277","explanation":"\nYou tried to use a type which doesn't implement some trait in a place which\nexpected that trait. Erroneous code example:\n\n```compile_fail,E0277\n// here we declare the Foo trait with a bar method\ntrait Foo {\n fn bar(&self);\n}\n\n// we now declare a function which takes an object implementing the Foo trait\nfn some_func<T: Foo>(foo: T) {\n foo.bar();\n}\n\nfn main() {\n // we now call the method with the i32 type, which doesn't implement\n // the Foo trait\n some_func(5i32); // error: the trait bound `i32 : Foo` is not satisfied\n}\n```\n\nIn order to fix this error, verify that the type you're using does implement\nthe trait. Example:\n\n```\ntrait Foo {\n fn bar(&self);\n}\n\nfn some_func<T: Foo>(foo: T) {\n foo.bar(); // we can now use this method since i32 implements the\n // Foo trait\n}\n\n// we implement the trait on the i32 type\nimpl Foo for i32 {\n fn bar(&self) {}\n}\n\nfn main() {\n some_func(5i32); // ok!\n}\n```\n\nOr in a generic context, an erroneous code example would look like:\n\n```compile_fail,E0277\nfn some_func<T>(foo: T) {\n println!(\"{:?}\", foo); // error: the trait `core::fmt::Debug` is not\n // implemented for the type `T`\n}\n\nfn main() {\n // We now call the method with the i32 type,\n // which *does* implement the Debug trait.\n some_func(5i32);\n}\n```\n\nNote that the error here is in the definition of the generic function: Although\nwe only call it with a parameter that does implement `Debug`, the compiler\nstill rejects the function: It must work with all possible input types. In\norder to make this example compile, we need to restrict the generic type we're\naccepting:\n\n```\nuse std::fmt;\n\n// Restrict the input type to types that implement Debug.\nfn some_func<T: fmt::Debug>(foo: T) {\n println!(\"{:?}\", foo);\n}\n\nfn main() {\n // Calling the method is still fine, as i32 implements Debug.\n some_func(5i32);\n\n // This would fail to compile now:\n // struct WithoutDebug;\n // some_func(WithoutDebug);\n}\n```\n\nRust only looks at the signature of the called function, as such it must\nalready specify all requirements that will be used for every type parameter.\n"},"level":"error","spans":[{"file_name":"json_error_demo.rs","byte_start":50,"byte_end":51,"line_start":4,"line_end":4,"column_start":7,"column_end":8,"is_primary":true,"text":[{"text":" a + b","highlight_start":7,"highlight_end":8}],"label":"no implementation for `{integer} + &str`","suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[{"message":"the trait `std::ops::Add<&str>` is not implemented for `{integer}`","code":null,"level":"help","spans":[],"children":[],"rendered":null}],"rendered":"error[E0277]: cannot add `&str` to `{integer}`\n --> json_error_demo.rs:4:7\n |\n4 | a + b\n | ^ no implementation for `{integer} + &str`\n |\n = help: the trait `std::ops::Add<&str>` is not implemented for `{integer}`\n\n"}
{"message":"aborting due to previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"error: aborting due to previous error\n\n"}
{"message":"For more information about this error, try `rustc --explain E0277`.","code":null,"level":"","spans":[],"children":[],"rendered":"For more information about this error, try `rustc --explain E0277`.\n"}
출력은 여러 줄로 이루어지며 각 줄은 하나의 JSON 객체입니다. 그러나 안타깝게도 이 줄들을 모두 합친 것은 유효한 JSON이 아니므로, 유효한 JSON이 필요한 도구나 기법(python3 -m json.tool로 파이프하기 등)을 사용할 수 없다는 점에 유의하십시오. (각 줄/객체를 플러시되는 즉시 보낼 수 있도록 LSP 성능을 위해 의도적으로 이렇게 만든 것이 아닐까 추측해 볼 수 있습니다.)
또한 “사람용” 출력을 문자열로 담고 있는 “rendered” 필드에도 유의하십시오. 이 필드는 UI 테스트가 모든 것을 두 번 컴파일하지 않고도 구조화된 JSON을 활용하는 동시에 “사람용” 출력(물론 색상은 제외하고)도 볼 수 있도록 도입되었습니다.
“사람“이 읽을 수 있는 형식의 이미터와 json 형식의 이미터는 rustc_errors 아래에 있으며, 둘 다 rustc_ast 크레이트에서 rustc_errors 크레이트로 옮겨졌습니다.
JSON 이미터는 JSON 직렬화를 위해 자체 Diagnostic 구조체(및 하위 구조체)를 정의합니다. 이것을 errors::Diag와 혼동하지 마십시오!
#[rustc_on_unimplemented]
이 속성을 사용하면 트레이트 정의에서 구현이 있어야 하는데 찾지 못했을 때의 오류 메시지를 수정할 수 있습니다. 속성 안의 문자열 리터럴은 형식 문자열이며, 이름 있는 매개변수로 형식을 지정할 수 있습니다. 어떤 매개변수를 쓸 수 있는지는 아래의 형식 지정 절을 참고하십시오.
#[rustc_on_unimplemented(message = "an iterator over \
elements of type `{A}` cannot be built from a \
collection of type `{Self}`")]
trait MyIterator<A> {
fn next(&mut self) -> A;
}
fn iterate_chars<I: MyIterator<char>>(i: I) {
// ...
}
fn main() {
iterate_chars(&[1, 2, 3][..]);
}
사용자가 이 코드를 컴파일하면 다음과 같은 내용을 보게 됩니다;
error[E0277]: an iterator over elements of type `char` cannot be built from a collection of type `&[{integer}]`
--> src/main.rs:13:19
|
13 | iterate_chars(&[1, 2, 3][..]);
| ------------- ^^^^^^^^^^^^^^ the trait `MyIterator<char>` is not implemented for `&[{integer}]`
| |
| required by a bound introduced by this call
|
note: required by a bound in `iterate_chars`
다음 항목의 내용을 수정할 수 있습니다:
- 주 오류 메시지(
message) - 레이블(
label) - 노트(
note)
예를 들어, 다음 속성은
#[rustc_on_unimplemented(message = "message", label = "label", note = "note")]
trait MyIterator<A> {
fn next(&mut self) -> A;
}
다음과 같은 출력을 생성합니다:
error[E0277]: message
--> <file>:10:19
|
10 | iterate_chars(&[1, 2, 3][..]);
| ------------- ^^^^^^^^^^^^^^ label
| |
| required by a bound introduced by this call
|
= help: the trait `MyIterator<char>` is not implemented for `&[{integer}]`
= note: note
note: required by a bound in `iterate_chars`
지금까지 설명한 기능은 #[diagnostic::on_unimplemented]로도 사용할 수 있습니다. 가능하다면 그것을 대신 사용하십시오.
필터링
더 상황에 맞는 오류 메시지를 제공할 수 있도록, on을 사용하여 이 필드들의 적용 여부를 필터링할 수 있습니다.
다음 불리언 플래그로 필터링할 수 있습니다:
crate_local: 트레이트 바운드를 충족하지 못하게 만든 코드가 사용자의 크레이트에 속하는지 여부입니다. 이 플래그는 의존성을 수정해야 하는 코드 변경을 제안하지 않도록 하는 데 사용됩니다.direct: 파생된 의무가 아니라 사용자가 지정한 의무인지 여부입니다.from_desugaring: 예를 들어?나try블록처럼 어떤 종류의 디슈가링 안에 있는지 여부입니다. 이 플래그로 매칭할 수도 있습니다. 아래를 참고하십시오.
name = "value" 형식을 사용하여 다음 이름과 값으로 매칭할 수 있습니다:
cause:ObligationCauseCode열거형의 한 변형과 매칭합니다."MainFunctionType"만 지원됩니다.from_desugaring:DesugaringKind열거형의 특정 변형과 매칭합니다. 디슈가링은 배리언트 이름으로 식별됩니다. 예를 들어?디슈가링은"QuestionMark",try블록은"TryBlock"입니다.Self = "alloc::string::String"나Rhs="i32"처럼Self와 트레이트의 모든 제네릭 인자입니다.
컴파일러는 매칭에 사용할 여러 값을 제공할 수 있습니다. 예를 들면 다음과 같습니다:
- 타입 인자를 해석한 형태와 해석하지 않은 형태로 보기 좋게 출력한 self_ty입니다.
- self_ty가 타입이 알려진 정수 타입이면
"{integral}"입니다. - 해당하는 경우
"[]","[{ty}]","[{ty}; _]","[{ty}; $N]"입니다. - 앞서 말한 슬라이스와 배열에 대한 참조입니다.
- self가 함수이면
"fn","unsafe fn"또는"#[target_feature] fn"입니다. - 타입이 숫자이지만 아직 추론되지 않았으면
"{integer}"와"{float}"입니다. - self를 ADT로 매칭하기 위한
"{struct}","{enum}","{union}"입니다. "[{integral}; _]"처럼 위 항목들을 조합한 것입니다.
예를 들어 Iterator 트레이트는 다음과 같이 필터링할 수 있습니다:
#[rustc_on_unimplemented(
on(Self = "&str", note = "call `.chars()` or `.as_bytes()` on `{Self}`"),
message = "`{Self}` is not an iterator",
label = "`{Self}` is not an iterator",
note = "maybe try calling `.iter()` or a similar method"
)]
pub trait Iterator {}
이는 다음과 같은 출력을 만들어 냅니다:
error[E0277]: `Foo` is not an iterator
--> src/main.rs:4:16
|
4 | for foo in Foo {}
| ^^^ `Foo` is not an iterator
|
= note: maybe try calling `.iter()` or a similar method
= help: the trait `std::iter::Iterator` is not implemented for `Foo`
= note: required by `std::iter::IntoIterator::into_iter`
error[E0277]: `&str` is not an iterator
--> src/main.rs:5:16
|
5 | for foo in "" {}
| ^^ `&str` is not an iterator
|
= note: call `.chars()` or `.bytes() on `&str`
= help: the trait `std::iter::Iterator` is not implemented for `&str`
= note: required by `std::iter::IntoIterator::into_iter`
on 필터는 cfg 속성과 비슷하게 all, any, not 술어를 받습니다:
#[rustc_on_unimplemented(on(
all(Self = "&str", T = "alloc::string::String"),
note = "you can coerce a `{T}` into a `{Self}` by writing `&*variable`"
))]
pub trait From<T>: Sized {
/* ... */
}
포매팅
문자열 리터럴은 중괄호로 감싼 매개변수를 받는 형식 문자열이지만, 위치 매개변수와 나열된 매개변수는 허용되지 않습니다. 사용할 수 있는 매개변수 이름은 다음과 같습니다:
Self와 트레이트의 모든 제네릭 매개변수입니다.This: 속성이 붙은 트레이트의 이름이며, 제네릭은 포함하지 않습니다.This:path: 속성이 붙은 트레이트의 전체 경로이며, 제네릭은 해석하지 않은 상태입니다.This:resolved: 속성이 붙은 트레이트의 전체 경로이며, 제네릭은 해석된 상태입니다. 또한 이 매개변수는Fn(...)트레이트를 “슈거” 형태로 표시합니다.ItemContext: 현재 위치한hir::Node의 종류로,"an async block","a function","an async function"등이 있습니다.
예를 들어 다음과 같은 코드는:
#![feature(rustc_attrs)]
#[rustc_on_unimplemented(message = "Self = `{Self}`, \
T = `{T}`, this = `{This}`, trait = `{Trait}`, \
context = `{ItemContext}`")]
pub trait From<T>: Sized {
fn from(x: T) -> Self;
}
fn main() {
let x: i8 = From::from(42_i32);
}
메시지를 다음과 같이 포매팅합니다.
"Self = `i8`, T = `i32`, this = `From`, trait = `From<i32>`, context = `a function`"
진단 구조체와 하위 진단 구조체
rustc에는 진단을 만드는 데 사용할 수 있는 두 가지 진단 트레이트인 Diagnostic과 Subdiagnostic이 있습니다.
간단한 진단에는 #[derive(Diagnostic)]과 같은 파생 impl을 사용할 수 있습니다. 이 방식은 추가 하위 진단을 붙일지 결정하는 데 로직이 많이 필요하지 않은 간단한 진단에만 적합합니다.
하위 진단을 조건에 따라 추가하거나, 렌더링 로직을 사용자 정의하거나, 런타임에 메시지를 선택하는 것처럼 진단에 더 복잡하거나 동적인 동작이 필요한 경우에는 해당 트레이트(Diagnostic 또는 Subdiagnostic)를 직접 구현해야 합니다. 이 방식은 더 큰 유연성을 제공하며, 단순하고 정적인 구조를 넘어서는 진단에 권장됩니다.
진단은 여러 언어로 번역될 수 있습니다.
#[derive(Diagnostic)]
아래에 나온 “field already declared” 진단의 정의를 살펴보십시오.
#[derive(Diagnostic)]
#[diag("field `{$field_name}` is already declared", code = E0124)]
pub struct FieldAlreadyDeclared {
pub field_name: Ident,
#[primary_span]
#[label("field already declared")]
pub span: Span,
#[label("`{$field_name}` first declared here")]
pub prev_span: Span,
}
Diagnostic은 구조체와 열거형에서만 derive할 수 있습니다. 구조체에서 타입에 붙이는 속성은 열거형에서는 각 배리언트에 붙입니다(그 반대도 마찬가지입니다). 각 Diagnostic에는 구조체 또는 각 열거형 배리언트에 적용된 #[diag(...)] 속성이 하나 있어야 합니다.
오류에 오류 코드(예: “E0624”)가 있으면 code 하위 속성을 사용해 지정할 수 있습니다. code를 반드시 지정해야 하는 것은 아니지만, Diag를 사용하는 진단을 Diagnostic을 사용하도록 옮기는 경우 기존에 코드가 있었다면 그대로 유지해야 합니다.
#[diag(..)]는 첫 번째 위치 인자로 메시지를 제공해야 합니다. 메시지는 영어로 작성하지만, 사용자가 요청한 로캘로 번역될 수 있습니다. 번역 가능한 오류 메시지를 작성하는 방법과 생성되는 방식에 대해 더 알아보려면 번역 문서를 참고하십시오.
어노테이션이 없는 Diagnostic의 모든 필드는 위 예제의 field_name처럼 Fluent 메시지에서 변수로 사용할 수 있습니다.
(타입이 Span인) 필드에 #[primary_span] 속성을 사용하면 해당 필드가 진단의 주 메시지가 표시될 주 스팬임을 나타냅니다.
진단은 주 메시지만으로 이루어지지 않으며, 레이블, 노트, 도움말 메시지, 제안을 포함하는 경우가 많습니다. 이들은 모두 Diagnostic에 지정할 수도 있습니다.
#[label], #[help], #[warning], #[note]는 모두 타입이 Span인 필드에 적용할 수 있습니다. 이 속성 중 하나를 적용하면 해당 Span을 가진 하위 진단이 만들어집니다. 이 속성들은 진단 메시지를 인자로 받습니다.
다른 타입은 Diagnostic derive에서 사용될 때 다음과 같이 특별하게 동작합니다:
Option<T>에 적용된 속성은 옵션이Some(..)일 때만 하위 진단을 내보냅니다.Vec<T>에 적용된 속성은 벡터의 각 요소마다 반복됩니다.
#[help], #[warning], #[note]는 구조체 자체에도 적용할 수 있습니다. 이 경우 하위 진단에 Span이 없다는 점만 빼면 필드에 적용했을 때와 똑같이 동작합니다. 이 속성들은 같은 효과를 내도록 () 타입의 필드에도 적용할 수 있으며, Option 타입과 함께 사용하면 선택적인 #[note]/#[help]/#[warning] 하위 진단을 나타낼 수 있습니다.
제안은 다음 네 가지 필드 속성 중 하나를 사용하여 내보낼 수 있습니다:
#[suggestion("message", code = "...", applicability = "...")]#[suggestion_hidden("message", code = "...", applicability = "...")]#[suggestion_short("message", code = "...", applicability = "...")]#[suggestion_verbose("message", code = "...", applicability = "...")]
제안은 Span 필드나 (Span, MachineApplicability) 필드 중 하나에 적용해야 합니다. 다른 필드 속성과 마찬가지로 사용자에게 표시될 메시지를 제공해야 합니다. code는 대체 코드로 제안할 코드를 지정하며, 형식 문자열입니다(예: {field_name}은 구조체의 field_name 필드 값으로 대체됩니다). applicability는 속성에서 적용 가능성을 지정하는 데 사용할 수 있지만, 필드의 타입에 Applicability가 포함된 경우에는 사용할 수 없습니다.
최종적으로 Diagnostic derive는 다음과 같은 Diagnostic 구현을 생성합니다:
impl<'a, G: EmissionGuarantee> Diagnostic<'a> for FieldAlreadyDeclared {
fn into_diag(self, dcx: &'a DiagCtxt, level: Level) -> Diag<'a, G> {
let mut diag = Diag::new(dcx, level, "field `{$field_name}` is already declared");
diag.set_span(self.span);
diag.span_label(
self.span,
"field already declared"
);
diag.span_label(
self.prev_span,
"`{$field_name}` first declared here"
);
diag
}
}
이제 진단을 정의했으니, 이를 어떻게 사용하겠습니까? 아주 간단합니다. 구조체의 인스턴스를 만들어 emit_err(또는 emit_warning)에 전달하기만 하면 됩니다:
tcx.dcx().emit_err(FieldAlreadyDeclared {
field_name: f.ident,
span: f.span,
prev_span,
});
#[derive(Diagnostic)] 참조
#[derive(Diagnostic)]는 다음 속성을 지원합니다:
#[diag("message", code = "...")]- 구조체 또는 열거형 배리언트에 적용됩니다.
- 필수
- 진단과 연결될 텍스트와 오류 코드를 정의합니다.
- 메시지(필수)
- 사용자에게 표시될 진단 메시지입니다.
- 번역 문서를 참조하십시오.
code = "..."(선택)- 오류 코드를 지정합니다.
#[note("message")](선택)- 구조체 또는 타입이
Span,Option<()>,bool,()인 구조체 필드에 적용됩니다. - note 하위 진단을 추가합니다.
- 값은 note의 메시지입니다.
Span필드에 적용하면 스팬이 지정된 note를 생성합니다.
- 구조체 또는 타입이
#[help("message")](선택)- 구조체 또는 타입이
Span,Option<()>,bool,()인 구조체 필드에 적용됩니다. - help 하위 진단을 추가합니다.
- 값은 help 메시지입니다.
Span필드에 적용하면 스팬이 지정된 help를 생성합니다.
- 구조체 또는 타입이
#[label("message")](선택)Span필드에 적용됩니다.- label 하위 진단을 추가합니다.
- 값은 label의 메시지입니다.
#[warning("message")](선택)Span,Option<()>,bool또는()타입의 구조체나 구조체 필드에 적용됩니다.- 경고 하위 진단을 추가합니다.
- 값은 경고 메시지입니다.
#[suggestion("message", code = "...", applicability = "...", style = "...")](선택 사항)(Span, MachineApplicability)또는Span필드에 적용됩니다.- 제안 하위 진단을 추가합니다.
- 메시지 (필수)
- 값은 사용자에게 표시될 제안 메시지입니다.
- 번역 문서를 참고하십시오.
code = "..."/code("...", ...)(필수)- 대체용으로 제안할 코드를 나타내는 하나 이상의 형식 문자열입니다. 값이 여러 개이면 가능한 대체안이 여러 개임을 의미합니다.
applicability = "..."(선택 사항)machine-applicable,maybe-incorrect,has-placeholders,unspecified중 하나여야 하는 문자열입니다.
style = "..."(선택 사항)- 값은 제안의 스타일입니다.
normal,short,hidden,verbose,tool-only중 하나여야 하는 문자열입니다.
#[subdiagnostic]Subdiagnostic을 구현하는 타입(#[derive(Subdiagnostic)]로 생성)에 적용됩니다.- 하위 진단 구조체가 나타내는 하위 진단을 추가합니다.
#[primary_span](선택 사항)- _
Subdiagnostic의Span필드에 적용됩니다. - 진단의 주 스팬을 나타냅니다.
- _
#[derive(Subdiagnostic)]
컴파일러에서는 특정 하위 진단이 적용 가능한 경우 이를 오류에 조건부로 추가하는 함수를 작성하는 일이 흔합니다. 전체 진단은 진단 구조체로 나타낼 수 없더라도, 이러한 하위 진단은 진단 구조체로 나타낼 수 있는 경우가 많습니다. 이러한 상황에서는 Subdiagnostic derive를 사용하여 부분 진단(예: note, label, help 또는 suggestion)을 구조체로 나타낼 수 있습니다.
아래에 나온 “expected return type” 레이블의 정의를 살펴봅시다.
#![allow(unused)]
fn main() {
#[derive(Subdiagnostic)]
pub enum ExpectedReturnTypeLabel<'tcx> {
#[label("expected `()` because of default return type")]
Unit {
#[primary_span]
span: Span,
},
#[label("expected `{$expected}` because of return type")]
Other {
#[primary_span]
span: Span,
expected: Ty<'tcx>,
},
}
}
Diagnostic과 마찬가지로 Subdiagnostic도 구조체나 열거형에 대해 derive할 수 있습니다. 구조체에서 타입에 지정하는 속성은 열거형에서는 각 변형에 지정합니다(그 반대도 마찬가지입니다). 각 Subdiagnostic에는 구조체 또는 각 변형에 다음 중 하나의 속성이 적용되어야 합니다.
- 레이블을 정의하는
#[label(..)] - 노트를 정의하는
#[note(..)] - 도움말을 정의하는
#[help(..)] - 경고를 정의하는
#[warning(..)] - 제안을 정의하는
#[suggestion(..)]
위의 모든 항목은 첫 번째 위치 인자로 진단 메시지를 제공해야 합니다. 번역 가능한 오류 메시지가 생성되는 방식에 대해 자세히 알아보려면 번역 문서를 참고하십시오.
(Span 타입의) 필드에 #[primary_span] 속성을 사용하면 해당 필드가 하위 진단의 기본 스팬임을 나타냅니다. 기본 스팬은 스팬 없이 존재할 수 없는 레이블이나 제안에만 필요합니다.
타입/변형의 필드 중 어노테이션이 없는 모든 필드는 Fluent 메시지에서 변수로 사용할 수 있습니다.
Diagnostic과 마찬가지로 Subdiagnostic도 Option<T> 및 Vec<T> 필드를 지원합니다.
제안은 타입/변형에 다음 네 가지 속성 중 하나를 사용하여 내보낼 수 있습니다:
#[suggestion("...", code = "...", applicability = "...")]#[suggestion_hidden("...", code = "...", applicability = "...")]#[suggestion_short("...", code = "...", applicability = "...")]#[suggestion_verbose("...", code = "...", applicability = "...")]
제안을 사용하려면 필드에 #[primary_span]이 설정되어 있어야 하며, 제안은 다음과 같은 하위 속성을 가질 수 있습니다:
- 첫 번째 위치 인자는 사용자에게 표시될 메시지를 지정합니다.
code는 대체 코드로 제안할 코드를 지정하며, Fluent 식별자가 아닌 형식 문자열입니다(예:{field_name}은 구조체의field_name필드 값으로 대체됩니다).applicability는 속성에서 적용 가능성을 지정하는 데 사용할 수 있지만, 필드의 타입에Applicability가 포함된 경우에는 사용할 수 없습니다.
적용 가능성은 #[applicability] 속성을 사용하여 (Applicability 타입의) 필드로 지정할 수도 있습니다.
최종적으로 Subdiagnostic derive는 다음과 같은 Subdiagnostic 구현을 생성합니다:
#![allow(unused)]
fn main() {
impl<'tcx> Subdiagnostic for ExpectedReturnTypeLabel<'tcx> {
fn add_to_diag(self, diag: &mut rustc_errors::Diagnostic) {
use rustc_errors::{Applicability, IntoDiagArg};
match self {
ExpectedReturnTypeLabel::Unit { span } => {
diag.span_label(span, "expected `()` because of default return type")
}
ExpectedReturnTypeLabel::Other { span, expected } => {
diag.set_arg("expected", expected);
diag.span_label(span, "expected `{$expected}` because of return type")
}
}
}
}
}
하위 진단을 정의한 후에는 진단의 subdiagnostic 함수에 전달하거나(예시 및 예시), 진단 구조체에서 #[subdiagnostic] 어노테이션이 달린 필드에 할당하여 사용할 수 있습니다.
인자 공유와 격리
하위 진단은 정보를 렌더링하기 전에 자신의 인자(즉, 자기 구조의 특정 필드)를 Diag 구조에 추가합니다. Diag 구조는 주 진단의 인자도 저장하므로 하위 진단에서도 주 진단의 인자를 사용할 수 있습니다.
그러나 #[derive(Subdiagnostic)]을 구현하여 하위 진단을 주 진단에 추가할 때는, rust-lang/rust#142724에서 도입된 다음 규칙이 인자(즉, Fluent 메시지에서 사용되는 변수) 처리에 적용됩니다.
하위 진단 간 인자 격리: 하위 진단이 설정한 인자는 해당 하위 진단을 렌더링하는 동안에만 사용할 수 있습니다. 하위 진단이 렌더링된 후에는 그 하위 진단이 도입한 모든 인자가 주 진단의 값으로 복원됩니다. 이를 통해 여러 하위 진단이 서로의 인자 스코프를 오염시키지 않도록 보장합니다. 예를 들어 Vec<Subdiag>를 사용할 때 각 하위 진단은 같은 인자를 반복해서 추가할 수 있습니다.
하위 진단과 주 진단 간 동일 인자 재정의: 하위 진단이 주 진단에 이미 있는 인자와 같은 이름의 인자를 설정하면, 두 값이 정확히 같지 않은 한 런타임에 오류를 보고합니다. 여기에는 두 가지 이점이 있습니다:
- 이는 주 진단의 인자가 하위 진단의 속성에 나타날 수 있는 유연성을 유지합니다. 예를 들어, 하위 진단에 있는
#[suggestion("...", code = "{new_vis}")]와 같은 속성은 메인 진단 구조체의 필드인new_vis를 참조할 수 있습니다. - 이는 주 진단이나 다른 하위 진단에 필요한 인자가 실수로 덮어쓰이거나 삭제되는 것을 방지합니다.
이 규칙들은 하위 진단이 주입한 인자가 엄격하게 자신의 렌더링 범위로 한정되도록 보장합니다. 이름 충돌이 있더라도 주 진단의 인자는 하위 진단 로직의 영향을 받지 않습니다. 또한 하위 진단은 필요할 때 같은 이름을 가진 주 진단의 인자에 접근할 수 있습니다.
#[derive(Subdiagnostic)] 레퍼런스
#[derive(Subdiagnostic)]는 다음 속성을 지원합니다:
#[label("message")],#[help("message")],#[warning("message")]또는#[note("message")]- 구조체 또는 열거형 배리언트에 적용됩니다. 구조체/열거형 배리언트 속성과 상호 배타적입니다.
- 필수
- 레이블, 도움말 또는 노트를 나타내는 타입을 정의합니다.
- 메시지 (필수)
- 사용자에게 표시될 진단 메시지입니다.
- 번역 문서를 참조하십시오.
#[suggestion("message", code = "...", applicability = "...", style = "...")]- 구조체 또는 열거형 배리언트에 적용됩니다. 구조체/열거형 배리언트 속성과 상호 배타적입니다.
- 필수
- 제안을 나타내는 타입을 정의합니다.
- 메시지 (필수)
- 사용자에게 표시될 진단 메시지입니다.
- 번역 문서를 참조하십시오.
code = "..."/code("...", ...)(필수)- 대체로 제안할 코드를 나타내는 하나 이상의 형식 문자열입니다. 값이 여러 개이면 가능한 대체안이 여러 개임을 뜻합니다.
applicability = "..."(선택)- 필드의
#[applicability]와 상호 배타적입니다. - 값은 제안의 적용 가능성입니다.
- 다음 중 하나여야 하는 문자열입니다:
machine-applicablemaybe-incorrecthas-placeholdersunspecified
- 필드의
style = "..."(선택 사항)- 값은 제안의 스타일입니다.
- 다음 중 하나여야 하는 문자열입니다:
normal(기본값)shorthiddenverbosetool-only
#[multipart_suggestion("message", applicability = "...", style = "...")]- 구조체 또는 열거형 배리언트에 적용됩니다. 구조체/열거형 배리언트 속성과는 상호 배타적입니다.
- 필수
- 다중 파트 제안(multipart suggestion)을 나타낼 타입을 정의합니다.
- 메시지(필수):
#[suggestion]을 참고하십시오 applicability = "..."(선택 사항):#[suggestion]을 참고하십시오style = "..."(선택 사항):#[suggestion]을 참고하십시오
#[primary_span](레이블과 제안에는 필수, 그 외에는 선택 사항, 다중 파트 제안에는 적용되지 않음)Span필드에 적용됩니다.- 하위 진단의 주 스팬을 나타냅니다.
#[suggestion_part(code = "...")](필수, 다중 파트 제안에만 적용 가능)Span필드에 적용됩니다.- 해당 스팬이 다중 파트 제안의 한 부분임을 나타냅니다.
code = "..."(필수)- 값은 교체용으로 제안할 코드를 나타내는 형식 문자열입니다.
#[applicability](선택 사항, (단순 및 다중 파트) 제안에만 적용 가능)Applicability필드에 적용됩니다.- 제안의 적용 가능성을 나타냅니다.
번역
진행 상황은 추적 이슈 https://github.com/rust-lang/rust/issues/132181를 참고하십시오.
번역 인프라는 아직 제안되지 않은 재설계와 그에 따른 재작업을 기다리고 있으므로, 현재 번역 인프라의 사용을 의무화하지 않습니다. 인프라를 사용하고 싶거나 인프라를 쓰는 편이 코드를 더 깔끔하게 만든다면 사용하되, 그렇지 않고 더 많은 유연성이 필요하다면 번역 인프라를 우회하십시오.
rustc의 진단 인프라는 Fluent를 사용하여 번역 가능한 진단을 지원합니다.
번역 가능한 진단 작성하기
번역 가능한 진단을 작성하는 방법에는 두 가지가 있습니다:
- 단순한 진단의 경우, 진단(또는 하위 진단) derive를 사용합니다. (“단순한” 진단이란 하위 진단을 내보낼지 결정하는 데 많은 로직이 필요하지 않아서 진단 구조체로 표현할 수 있는 진단을 말합니다.) 진단 및 하위 진단 구조체 문서를 참고하십시오.
DiagAPI와 함께 타입이 지정된 식별자를 사용합니다(Diagnostic또는Subdiagnostic구현 안에서).
번역 가능한 진단을 추가하거나 변경할 때 번역은 신경 쓰지 않아도 됩니다. 원본 영어 메시지만 수정하면 됩니다.
Fluent
Fluent는 “비대칭 현지화(asymmetric localization)“라는 개념을 바탕으로 만들어졌으며, 이는 번역의 표현력을 원본 언어(rustc의 경우 영어)의 문법에서 분리하는 것을 목표로 합니다. 번역 기능이 도입되기 전에는 rustc의 진단이 사용자에게 표시되는 메시지를 빌드하기 위해 보간에 크게 의존했습니다. 보간된 문자열은 번역하기 어렵습니다. 자연스러운 번역을 쓰려면 영어 문자열보다 보간이 더 많이, 더 적게, 또는 다르게 필요할 수 있는데, 이 모든 경우를 지원하려면 컴파일러의 소스 코드를 변경해야 하기 때문입니다.
진단 메시지는 Fluent 리소스에 정의됩니다. 특정 로캘(예: en-US)에 대한 Fluent 리소스를 모두 합친 집합을 Fluent 번들이라고 합니다.
typeck_address_of_temporary_taken = cannot take address of a temporary
위 예시에서 typeck_address_of_temporary_taken은 Fluent 메시지의 식별자이며, 영어로 된 진단 메시지에 대응합니다. 다른 언어의 메시지에 대응하는 Fluent 리소스를 따로 작성할 수 있습니다. 따라서 각 진단에는 적어도 하나의 Fluent 메시지가 있습니다.
typeck_address_of_temporary_taken = cannot take address of a temporary
.label = temporary value
관례적으로 하위 진단의 진단 메시지는 Fluent 메시지의 “속성(attribute)“으로 지정합니다(속성은 관련된 추가 메시지로, .<attribute-name> 구문으로 표기합니다). 위 예시에서 label은 typeck_address_of_temporary_taken의 속성이며, 이 진단에 추가되는 레이블의 메시지에 대응합니다.
진단 메시지는 타입이나 변수의 이름처럼 추가적인 맥락을 사용자에게 보여 줄 메시지에 보간하는 경우가 많습니다. Fluent 메시지에 들어갈 추가 컨텍스트는 진단에 “인자“로 제공됩니다.
typeck_struct_expr_non_exhaustive =
cannot create non-exhaustive {$what} using struct expression
위 예시에서 Fluent 메시지는 what이라는 이름의 인자를 참조하며, 이 인자는 존재한다고 가정됩니다(진단에 인자를 제공하는 방법은 뒤에서 자세히 다룹니다).
Fluent의 다른 사용 예시와 구문은 Fluent 문서를 참고하십시오.
메시지 이름 지정 지침
일반적으로 fluent는 메시지 이름 안에서 단어를 구분할 때 -를 사용합니다. 하지만 fluent는 _도 허용합니다. Rust 쪽 식별자도 _를 사용하므로 _가 Rust의 사용 사례에 더 잘 맞습니다. 따라서 rustc 내부에서는 단어 구분에 -를 사용할 수 없으며, 대신 _를 사용하는 것을 권장합니다. 유일한 예외는 -passes_see_issue와 같은 메시지 이름에서 맨 앞에 오는 -입니다.
번역 가능한 메시지 작성 지침
메시지를 여러 언어로 번역할 수 있으려면, 영어 메시지에 필요한 정보뿐만 아니라 어떤 언어에서든 필요한 모든 정보를 진단에 인자로 제공해야 합니다.
컴파일러 팀이 여러 언어로 번역하는 데 필요한 모든 정보를 갖춘 진단을 작성하는 경험을 더 쌓아 감에 따라, 이 페이지에 더 많은 지침이 추가될 것입니다. 현재로서는 Fluent 문서에 메시지를 여러 로캘로 번역하는 훌륭한 예시와, 이를 위해 코드가 제공해야 하는 정보가 잘 나와 있습니다.
컴파일 타임 검증과 타입이 지정된 식별자
rustc의 #[derive(Diagnostic)] 매크로는 Fluent 메시지에 대한 컴파일 타임 검증을 수행합니다. Fluent 리소스에 대한 컴파일 타임 검증은 컴파일러를 빌드하는 동안 Fluent 리소스의 파싱 오류를 모두 보고하므로, 잘못된 Fluent 리소스로 인해 컴파일러에서 패닉이 발생하는 것을 방지합니다. 또한 컴파일 타임 검증은 여러 Fluent 메시지가 같은 식별자를 가지는 경우에도 오류를 보고합니다.
내부 구조
번역을 지원하기 위해 rustc 진단 내부 구조의 여러 부분이 수정되었습니다.
메시지
rustc의 기존 진단 API(예: struct_span_err나 note)는 모두 DiagMessage로 변환할 수 있는 메시지라면 무엇이든 받습니다.
rustc_error_messages::DiagMessage는 번역할 수 없는 레거시 진단 메시지와 번역 가능한 메시지를 모두 표현할 수 있습니다. 번역할 수 없는 메시지는 단순한 String입니다. 번역 가능한 메시지는 Fluent 메시지의 식별자를 담은 &'static str일 뿐입니다(경우에 따라 속성을 담은 &'static str가 추가로 붙기도 합니다).
DiagMessage를 직접 다룰 필요는 전혀 없습니다. Fluent 리소스의 각 진단 메시지마다 DiagMessage 상수가 생성되거나(아래에서 자세히 설명합니다), 진단 derive의 매크로가 생성한 코드에서 DiagMessage가 만들어집니다.
DiagMessage는 문자열로 변환할 수 있는 모든 타입에 대해 Into를 구현하며, 이러한 타입을 번역할 수 없는 진단으로 변환합니다. 덕분에 기존의 모든 진단 호출이 그대로 동작합니다.
인자
메시지 내용에 보간되는 Fluent 메시지의 추가 컨텍스트를 번역 가능한 진단에 제공해야 합니다.
진단에는 이러한 추가 컨텍스트를 진단에 제공하는 데 사용할 수 있는 set_arg 함수가 있습니다.
인자는 이름(예: 앞의 예시에서 “what”)과 값을 모두 가집니다. 인자 값은 DiagArgValue 타입으로 표현되며, 이 타입은 단순히 문자열 또는 숫자입니다. rustc 타입은 문자열이나 숫자로 변환하는 방식으로 IntoDiagArg를 구현할 수 있으며, Ty<'tcx> 같은 일반적인 타입에는 이미 그러한 구현이 있습니다.
set_arg 호출은 진단 derive가 투명하게 처리하지만, 진단 빌더 API를 사용할 때는 직접 추가해야 합니다.
린트
이 페이지에서는 린트 등록을 둘러싼 몇 가지 메커니즘과 컴파일러에서 린트를 실행하는 방식을 설명합니다.
LintStore는 모든 것이 그 주위를 도는 핵심 인프라입니다. LintStore는 Session의 일부로 보관되며, Session이 생성된 직후 린트 목록으로 채워집니다.
린트와 린트 패스
컴파일러의 린트 메커니즘은 린트와 린트 패스라는 두 부분으로 구성됩니다. 안타깝게도 기존 문서 중 상당수는 이 둘을 모두 그냥 “린트“라고 부릅니다.
먼저 린트 선언 자체가 있으며, 이름과 기본 린트 수준 및 기타 메타데이터는 여기에서 나옵니다. 린트 선언은 일반적으로 declare_lint! 매크로로 정의되며, 이 매크로는 결국 &rustc_lint_defs::Lint 타입의 static으로 귀결됩니다(다만 모든 매크로가 그렇듯 이 매크로도 새 필드를 추가하기가 다소 번거로우므로 향후 바뀔 수 있습니다).
매크로를 사용하지 않고 직접 선언하는 것은 린트로 금지합니다.
린트 선언은 어떠한 “상태“도 가지지 않으며, 린트의 전역 식별자이자 설명일 뿐입니다. 런타임에 린트가 (린트 이름 기준으로) 두 번 등록되지 않았는지 단언합니다.
린트 패스는 모든 린트의 핵심입니다. 특히 린트와 린트 패스는 일대일 관계가 아닙니다. 어떤 린트는 그것을 내보내는 린트 패스가 하나도 없을 수도 있고, 여러 개일 수도 있으며, 단 하나일 수도 있습니다. 컴파일러는 패스가 특정 린트와 어떤 식으로든 연관되어 있는지 추적하지 않으며, 린트는 다른 작업(예: 타입 검사 등)의 일부로 내보내지는 경우가 많습니다.
등록
고수준 개요
rustc_interface::run_compiler에서 LintStore가 생성되고 모든 린트가 등록됩니다.
린트의 ’출처’는 세 가지입니다.
- 내부 린트: rustc 코드베이스에서만 사용하는 린트
- 내장 린트: 컴파일러에 내장되어 있으며 외부 출처에서 제공하지 않는 린트
rustc_interface::Configregister_lints: 생성 과정에서 컴파일러에 전달되는 린트
린트는 LintStore::register_lint 함수를 통해 등록됩니다. 이 등록은 각 린트마다 단 한 번만 이루어져야 하며, 그렇지 않으면 ICE가 발생합니다.
등록이 완료되면 린트 저장소를 Arc에 넣어 “동결“합니다.
린트 패스는 여러 범주(확장 전, early, late, late 모듈) 중 하나에 따로 등록됩니다. 패스는 클로저, 즉 impl Fn() -> Box<dyn X>로 등록되며, 여기서 dyn X는 early 또는 late 린트 패스 트레이트 객체입니다. 린트 패스를 실행할 때는 클로저를 실행한 다음 린트 패스 메서드를 호출합니다. 린트 패스 메서드는 &mut self를 받으므로 내부적으로 상태를 추적할 수 있습니다.
내부 린트
이들은 컴파일러나 clippy 같은 드라이버에서만 사용하는 린트입니다. 이 린트들은 rustc_lint::internal에서 찾을 수 있습니다.
이러한 린트의 한 예로, 린트 패스가 직접 구현되지 않고 declare_lint_pass! 매크로를 사용하여 구현되었는지 확인하는 검사가 있습니다. 이는 LINT_PASS_IMPL_WITHOUT_MACRO 린트를 통해 수행됩니다.
이러한 린트의 등록은 rustc_lint::new_lint_store 내부에서 새 린트 저장소를 생성할 때 호출되는 rustc_lint::register_internals 함수에서 이루어집니다.
내장 린트
이들은 주로 rustc_lint_defs::builtin과 rustc_lint::builtin 두 곳에 기술되어 있습니다. 대개 전자는 린트 자체의 정의를 제공하고 후자는 린트 패스 정의(및 구현)를 제공하지만, 항상 그런 것은 아닙니다.
내장 린트의 등록은 rustc_lint::register_builtins 함수에서 이루어집니다. 내부 린트와 마찬가지로, 이 역시 rustc_lint::new_lint_store 내부에서 이루어집니다.
드라이버 린트
이들은 드라이버가 rustc_interface::Config의 register_lints 필드(콜백)를 통해 제공하는 린트입니다. 드라이버는 이 필드가 이미 설정되어 있으면, 자신이 추가하는 콜백 안에서 현재 설정된 함수를 호출해야 합니다. 드라이버가 여기에 접근하는 가장 좋은 방법은 Config 구조체에 직접 접근할 수 있게 해 주는 Callbacks::config 함수를 오버라이드하는 것입니다.
컴파일러 린트 패스는 하나의 패스로 결합됩니다
컴파일러 내부에서는 성능상의 이유로 보통 수십 개의 린트 패스를 등록하지 않습니다. 대신 각 종류마다 하나의 린트 패스(예: BuiltinCombinedLateLintModPass)를 두고, 이 패스가 내부적으로 개별 린트 패스를 모두 호출합니다. 이렇게 하면 (대개 비어 있는) 각 트레이트 메서드에 대해 동적 디스패치 대신 정적 디스패치의 이점을 얻을 수 있기 때문입니다.
이상적으로는 이렇게 할 필요가 없어야 합니다. 코드를 이해하기가 더 복잡해지기 때문입니다. 그러나 현재의 타입 소거된 린트 저장소 방식에서는 성능상의 이유로 이렇게 하는 것이 유리합니다.
오류 코드
일반적으로 각 오류 메시지에는 E0123과 같은 고유 코드를 부여하려고 합니다. 이러한 코드는 컴파일러 내 각 크레이트에 있는 diagnostics.rs 파일에 정의되어 있으며, 이 파일은 기본적으로 매크로로 구성되어 있습니다. 모든 오류 코드에는 관련 설명이 있으며, 새로운 오류 코드에는 반드시 설명이 포함되어야 합니다. 다만 모든 과거의(더 이상 발생하지 않는) 오류 코드에 설명이 있는 것은 아니라는 점에 유의하십시오.
오류 설명
설명은 Markdown으로 작성되며(구문에 관한 세부 사항은 CommonMark Spec을 참고하십시오), 모든 설명은 rustc_error_codes 크레이트에 연결되어 있습니다. 긴 오류 코드의 형식과 작성 방법에 대한 자세한 내용은 RFC 1567을 읽어 보십시오. 2026년 3월 기준으로, 대체로 낡은 이 RFC를 더 유연한 새 표준으로 대체하려는 노력1이 진행 중입니다.
오류 설명은 오류 메시지를 보충하여 오류가 왜 발생하는지에 대한 세부 정보를 제공해야 합니다. 사용자가 빠른 수정 방법을 복사하여 붙여 넣게 하는 것은 도움이 되지 않습니다. 설명은 사용자가 자신의 코드가 왜 컴파일러에서 허용될 수 없는지 이해하도록 도와야 합니다. Rust는 도움이 되는 오류 메시지를 자랑으로 여기며, 긴 형식의 설명도 예외가 아닙니다. 그러나 오류 설명이 전면 개편되기 전1까지는 설명을 정확히 어떻게 작성해야 하는지가 다소 열려 있으므로, 언제나처럼 리뷰어에게 묻거나 Rust Zulip에서 문의하십시오.
새 코드 할당하기
오류 코드는 compiler/rustc_error_codes에 저장되어 있습니다.
새 오류를 만들려면 먼저 사용 가능한 다음 코드를 찾아야 합니다. rustc_error_codes/src/lib.rs를 열고 error_codes! 매크로 선언의 끝까지 스크롤하면 찾을 수 있습니다.
여기서 사용 중인 가장 높은 오류 코드가 E0805임을 확인할 수 있으므로, 아마도 E0806을 사용하면 될 것입니다. 확실히 하려면 rg E0806을 실행하여 확인하십시오. 참조가 하나도 없어야 합니다.
오류에 대한 확장 설명을 작성해야 하며, 이 설명은 rustc_error_codes/src/error_codes/E0806.md에 들어갑니다. 오류를 등록하려면 다음과 같이 error_codes! 매크로에 코드를 (올바른 번호 순서에 맞게) 추가하십시오.
#![allow(unused)]
fn main() {
macro_rules! error_codes {
...
0806,
}
}
실제로 오류를 발생시키려면 struct_span_code_err! 매크로를 사용할 수 있습니다.
#![allow(unused)]
fn main() {
struct_span_code_err!(self.dcx(), // some path to the `DiagCtxt` here
span, // whatever span in the source you want
E0806, // your new error code
fluent::example::an_error_message)
.emit() // actually issue the error
}
노트나 기타 코드 조각을 추가하려면 .emit()을 호출하기 전에 메서드를 호출할 수 있습니다.
#![allow(unused)]
fn main() {
struct_span_code_err!(...)
.span_label(another_span, fluent::example::example_label)
.span_note(another_span, fluent::example::separate_note)
.emit()
}
오류 코드를 추가하는 PR의 예시는 #76143을 참고하십시오.
오류 코드 doctest 실행하기
rustc_error_codes/src/error_codes에 추가된 예제를 테스트하려면 다음 명령으로 오류 인덱스 생성기를 실행하십시오.
./x test ./src/tools/error_index_generator
진단 항목
린트를 작성할 때는 특정 타입, 트레이트, 함수를 검사하는 경우가 많습니다. 그렇다면 이것들을 어떻게 검사해야 하는지에 대한 의문이 생깁니다. 타입은 전체 타입 경로로 검사할 수 있습니다. 하지만 이 방법은 경로를 하드 코딩해야 하며, 일부 엣지 케이스에서는 잘못 분류될 수 있습니다. 이를 해결하기 위해 rustc는 Symbol을 통해 타입을 식별하는 데 사용하는 진단 항목을 도입했습니다.
진단 항목 찾기
진단 항목은 rustc_diagnostic_item 속성을 사용하여 rustc/std/core/alloc 내부의 항목에 추가됩니다. 특정 타입에 대한 항목은 문서에서 소스 코드를 열어 이 속성을 찾아보면 확인할 수 있습니다. 테스트 중 컴파일 오류를 피하기 위해 이 속성은 cfg_attr 속성과 함께 추가되는 경우가 많다는 점에 유의하십시오. 정의는 보통 다음과 같은 형태입니다:
// This is the diagnostic item for this type vvvvvvv
#[cfg_attr(not(test), rustc_diagnostic_item = "Penguin")]
struct Penguin;
진단 항목은 일반적으로 트레이트, 타입, 독립 함수에만 추가됩니다. 연관 타입이나 메서드를 검사하는 것이 목적이라면, 해당 항목의 진단 항목을 사용하고 진단 항목 사용하기를 참고하십시오.
진단 항목 추가하기
새 진단 항목은 다음 두 단계로 추가할 수 있습니다:
-
Rust 저장소에서 대상 항목을 찾으십시오. 이제
rustc_diagnostic_item속성을 통해 진단 항목을 문자열로 추가하십시오. 이로 인해 테스트를 실행하는 동안 컴파일 오류가 발생하는 경우가 있습니다. 이러한 오류는not(test)조건과 함께cfg_attr속성을 사용하면 피할 수 있습니다(예방 차원에서 모든rustc_diagnostic_item속성에 이렇게 추가해도 괜찮습니다). 최종적으로는 다음과 같은 형태가 되어야 합니다:// This will be the new diagnostic item vvv #[cfg_attr(not(test), rustc_diagnostic_item = "Cat")] struct Cat;진단 항목의 명명 규칙에 대해서는 명명 규칙을 참고하십시오.
-
코드에서 진단 항목은
rustc_span::symbol::sym에 있는 심볼을 통해 접근합니다. 새로 만든 진단 항목을 추가하려면 모듈 파일을 열고 목록의 올바른 위치에 이름(이 경우Cat)을 추가하기만 하면 됩니다.
이제 변경 사항으로 풀 리퀘스트를 생성할 수 있습니다.
참고: Clippy와 같은 다른 프로젝트에서 진단 항목을 사용하는 경우, 저장소가 동기화될 때까지 시간이 걸릴 수 있습니다.
명명 규칙
진단 항목에는 아직 명명 규칙이 없습니다. 다음은 앞으로 따라야 할 몇 가지 지침이지만, 기존 이름과는 다를 수 있습니다:
- 타입, 트레이트, 열거형은 UpperCamelCase로 이름을 짓습니다(예:
Iterator와HashMap) Writer처럼 여러 번 사용되는 타입 이름은 모듈 이름을 덧붙이는 등의 방법으로 더 정확한 이름을 선택하는 것이 좋습니다(예:IoWriter)- 연관 항목에는 별도의 진단 항목을 부여하지 않아야 하며, 대신 해당 항목이 속한 타입의 진단 항목을 통해 간접적으로 접근해야 합니다.
std::mem::swap()과 같은 독립 함수는 중요한 (내보내기) 모듈 하나를 접두사로 붙여snake_case로 이름을 지어야 합니다(예:mem_swap과cmp_max)- 모듈에는 보통 진단 항목을 붙이지 않아야 합니다. 진단 항목은 경로 사용을 피하기 위해 추가되었으므로, 모듈에 진단 항목을 사용하면 오히려 역효과가 날 가능성이 큽니다.
진단 항목 사용하기
rustc에서 진단 항목은 rustc_span::symbol::sym 모듈 안의 Symbol을 통해 조회합니다. 그런 다음 TyCtxt::get_diagnostic_item()을 사용해 DefId에 매핑하거나, TyCtxt::is_diagnostic_item()을 사용해 DefId와 일치하는지 확인할 수 있습니다. 진단 항목을 DefId에 매핑할 때, 이 메서드는 Option<DefId>를 반환합니다. 심볼이 진단 항목이 아니거나, 예를 들어 #[no_std]로 컴파일할 때처럼 타입이 등록되지 않은 경우에는 이 값이 None일 수 있습니다. 이어지는 모든 예제는 DefId와 그 사용법을 바탕으로 합니다.
예제: 타입 확인하기
#![allow(unused)]
fn main() {
use rustc_span::symbol::sym;
/// This example checks if the given type (`ty`) has the type `HashMap` using
/// `TyCtxt::is_diagnostic_item()`
fn example_1(cx: &LateContext<'_>, ty: Ty<'_>) -> bool {
match ty.kind() {
ty::Adt(adt, _) => cx.tcx.is_diagnostic_item(sym::HashMap, adt.did()),
_ => false,
}
}
}
예제: 트레이트 구현 확인하기
#![allow(unused)]
fn main() {
/// This example checks if a given [`DefId`] from a method is part of a trait
/// implementation defined by a diagnostic item.
fn is_diag_trait_item(
cx: &LateContext<'_>,
def_id: DefId,
diag_item: Symbol
) -> bool {
if let Some(trait_did) = cx.tcx.trait_of_item(def_id) {
return cx.tcx.is_diagnostic_item(diag_item, trait_did);
}
false
}
}
연관 타입
진단 항목의 연관 타입은 먼저 트레이트의 DefId를 얻은 다음 TyCtxt::associated_items()를 호출하는 방식으로 간접적으로 접근할 수 있습니다. 이 메서드는 추가 확인에 사용할 수 있는 AssocItems 객체를 반환합니다. 사용 예시는 clippy_utils::ty::get_iterator_item_ty()를 참고하십시오.
Clippy에서의 사용
Clippy는 가능한 곳에서는 진단 항목을 사용하려 하며, 몇 가지 래퍼 함수와 유틸리티 함수를 개발했습니다. Clippy에서 진단 항목을 사용할 때는 해당 문서도 함께 참고하십시오. (린트 작성을 위한 공통 도구를 참고하십시오.)
관련 이슈
이 내용은 아마 이 주제를 정말 깊이 파고들고 싶은 분들에게만 흥미로울 것입니다 :)
- rust#60966: 진단 항목을 도입한 Rust PR
- rust-clippy#5393: 하드코딩된 경로 대신 진단 항목을 사용하도록 전환하기 위한 Clippy의 추적 이슈
ErrorGuaranteed
앞 절에서는 컴파일러 사용자가 보게 되는 오류 메시지를 다루었습니다. 하지만 오류를 내보내는 일은 컴파일러 소스 코드 내부에서 두 번째로 중요한 부수 효과도 낳을 수 있습니다. 바로 ErrorGuaranteed를 생성한다는 것입니다.
ErrorGuaranteed는 rustc_errors 크레이트 밖에서는 생성할 수 없는 크기가 0인 타입입니다. 이 값은 사용자에게 오류가 보고될 때마다 생성되므로, 컴파일러 코드에서 ErrorGuaranteed 타입의 값을 마주친다면 해당 컴파일은 실패가 정적으로 보장됩니다. 오류 코드 경로가 실패로 이어진다는 것을 정적으로 검사할 수 있으므로, 이는 건전성(soundness)을 해치는 버그를 방지하는 데 유용합니다.
ErrorGuaranteed를 사용할 때 고려해야 할 몇 가지 중요한 사항은 다음과 같습니다.
- 이 타입은 오류의 종류에 관한 정보를 전달하지 않습니다. 예를 들어, 해당 오류는 지연된 버그나 다른 컴파일러 오류로 인해 (간접적으로) 발생한 것일 수 있습니다. 따라서 오류를 내보낼지 여부나 어떤 종류의 오류를 내보낼지 결정할 때
ErrorGuaranteed에 의존해서는 안 됩니다. ErrorGuaranteed는 컴파일이 앞으로 오류를 내보낼 것임을 나타내는 데 사용해서는 안 됩니다. 이 타입은 오류가 이미 내보내졌음을, 즉emit()함수가 이미 호출되었음을 나타내는 데 사용해야 합니다. 예를 들어, 컴파일러의 이후 단계에서 오류가 발생할 것임을 감지하더라도, 먼저 직접 오류나 지연된 버그를 내보내지 않는 한ErrorGuaranteed를 사용할 수 없습니다.
다행히 대부분의 경우 ErrorGuaranteed를 오용하는 것은 정적으로 불가능할 것입니다.
분석
이 부분에서는 컴파일러가 코드의 다양한 속성을 검사하고 이후 단계에 정보를 제공하는 데 사용하는 여러 분석을 다룹니다. 일반적으로 사람들이 “Rust의 타입 시스템“이라고 말할 때 가리키는 것이 바로 이것입니다. 여기에는 타입의 표현, 추론, 검사와 트레이트 시스템, 그리고 빌림 검사기가 포함됩니다. 이러한 분석은 하나의 큰 패스나 연속된 패스들의 집합으로 이루어지지 않습니다. 오히려 컴파일 과정의 여러 부분에 걸쳐 분산되어 있으며, 서로 다른 중간 표현을 사용합니다. 예를 들어 타입 검사는 HIR에서 이루어지는 반면, 빌림 검사는 MIR에서 이루어집니다. 그럼에도 불구하고 설명의 편의를 위해 가이드의 이 부분에서 이러한 분석을 모두 다루겠습니다.
제네릭 매개변수 정의
이 장에서는 rustc가 어떤 제네릭 매개변수가 도입되는지를 어떻게 추적하는지 설명합니다. 예를 들어 struct Foo<T>가 주어졌을 때, rustc는 Foo가 타입 매개변수 T를 정의한다는 것(그리고 다른 제네릭 매개변수는 정의하지 않는다는 것)을 어떻게 추적합니까?
이 장에서는 for<'a> 구문(예: where 절이나 fn 타입)으로 도입되는 제네릭 매개변수를 추적하는 방법은 다루지 않습니다. 이 내용은 Binder에 관한 장에서 별도로 다룹니다.
ty::Generics
아이템이 도입하는 제네릭 매개변수는 ty::Generics 구조체로 추적합니다. 아이템에 따라서는 부모 아이템에 정의된 제네릭을 사용할 수 있습니다. 이는 ty::Generics 구조체에 제네릭 매개변수를 상속받을 부모 아이템을 지정하는 선택적 필드를 두는 방식으로 구현됩니다. 예를 들어, 다음 코드가 있다고 합시다.
trait Trait<T> {
fn foo<U>(&self);
}
foo에 사용되는 ty::Generics는 [U]를 포함하고 부모로 Some(Trait)를 가집니다. Trait는 [Self, T]를 포함하고 부모가 None인 ty::Generics를 가집니다.
GenericParamDef 구조체는 ty::Generics 목록에 있는 개별 제네릭 매개변수 각각을 표현하는 데 사용됩니다. GenericParamDef 구조체에는 제네릭 매개변수에 관한 정보가 들어 있습니다. 예를 들어 이름, defid, 매개변수의 종류(즉, 타입, 상수, 라이프타임) 등이 있습니다.
GenericParamDef에는 매개변수의 위치(가장 바깥쪽 부모부터 셈)를 나타내는 u32 인덱스도 들어 있으며, 이 값은 제네릭 매개변수의 사용을 표현하는 데 쓰입니다(자세한 내용은 타입 표현에 관한 장을 참고하십시오).
흥미롭게도 현재 ty::Generics는 아이템에 정의된 제네릭 매개변수를 모두 포함하지는 않습니다. 함수의 경우에는 조기 바인딩(early bound) 매개변수만 포함합니다.
EarlyBinder와 매개변수 인스턴스화
제네릭 매개변수 T를 도입하는 아이템이 있을 때, foo 바깥에서 foo 내부의 타입(즉, 반환 타입이나 인자 타입)을 참조할 때마다 foo에 정의된 제네릭 매개변수를 주의해서 처리해야 합니다. 예를 들면 다음과 같습니다.
fn foo<T, U>(a: T, _b: U) -> T { a }
fn main() {
let c = foo::<i32, u128>(1, 2);
}
main을 타입 검사할 때 단순히 foo의 반환 타입을 보고 변수 c에 타입 T를 할당할 수는 없습니다. 함수 main은 어떤 제네릭 매개변수도 정의하지 않으므로, 이 맥락에서 T는 전혀 의미가 없습니다. 더 일반적으로 말하면, 아이템이 제네릭 매개변수를 도입(바인딩)할 때마다, 바깥에서 그 아이템 내부의 타입에 접근할 때는 제네릭 매개변수를 바깥 아이템의 값으로 인스턴스화해야 합니다.
rustc에서는 이를 EarlyBinder 타입으로 추적합니다. foo의 반환 타입은 EarlyBinder<Ty>로 표현되며, Ty에 접근하는 유일한 방법은 Ty가 사용할 수 있는 모든 제네릭 매개변수에 대한 인자를 제공하는 것입니다. 이는 EarlyBinder::instantiate 메서드를 통해 구현되며, 이 메서드는 바인더를 해제하여 모든 제네릭 매개변수가 제공된 인자로 대체된 내부 값을 반환합니다.
앞의 예제로 돌아가면, main을 타입 검사할 때 foo의 반환 타입은 EarlyBinder(T/#0)로 표현됩니다. 그런 다음, 제네릭 인자로 i32, u128을 사용하여 함수를 호출했으므로, 반환 타입에 대해 [i32, u128]을 인자로 하여 EarlyBinder::instantiate를 호출합니다. 그 결과 인스턴스화된 반환 타입 i32를 얻게 되며, 이를 지역 변수 c의 타입으로 사용할 수 있습니다.
다음은 몇 가지 추가 예제입니다.
fn foo<T>() -> Vec<(u32, T)> { Vec::new() }
fn bar() {
// the return type of `foo` before instantiating it would be:
// `EarlyBinder(Adt(Vec, &[Tup(&[u32, T/#=0])]))`
// we then instantiate the binder with `[u64]` resulting in the type:
// `Adt(Vec, &[Tup(&[u32, u64])])`
let a = foo::<u64>();
}
struct Foo<A, B> {
x: Vec<A>,
..
}
fn bar(foo: Foo<u32, f32>) {
// the type of `foo`'s `x` field before instantiating it would be:
// `EarlyBinder(Vec<A/#0>)`
// we then instantiate the binder with `[u32, f32]` as those are the
// generic arguments to the `Foo` struct. This results in a type of:
// `Vec<u32>`
let y = foo.x;
}
컴파일러에서 이를 위한 instantiate 호출은 FieldDef::ty(소스)에서 이루어지며, bar를 타입 검사하는 도중 어느 시점에 foo.x의 타입을 얻기 위해 결국 FieldDef::ty(x, &[u32, f32])를 호출하게 됩니다.
인덱스에 관한 참고: Param의 인덱스가 EarlyBinder가 바인딩하는 것과 일치하지 않으면 버그입니다. 예를 들어, 인덱스가 범위를 벗어나거나 라이프타임의 인덱스가 타입 매개변수에 해당하는 경우입니다. 이러한 종류의 오류는 컴파일러의 더 이른 단계인 이름 해석 중에 잡히며, 이 단계에서는 내부 아이템이 이름으로 참조할 수 없어야 하는 아이템이 도입한 제네릭 매개변수에 대한 참조를 허용하지 않습니다.
앞서 언급했듯이 아이템의 바깥에 있을 때는 내부 값에 접근하기 전에 EarlyBinder를 제네릭 인자로 인스턴스화하는 것이 중요하지만, 개념적으로 이미 바인더 내부에 있는 경우의 설정은 약간 다릅니다.
예를 들면 다음과 같습니다.
#![allow(unused)]
fn main() {
impl<T> Trait for Vec<T> {
fn foo(&self, b: Self) {}
}
}
b 매개변수의 타입을 나타내는 Ty를 구성할 때, 우리가 속해 있는 impl의 Self 타입을 얻어야 합니다. 이는 impl의 DefId로 type_of 쿼리를 호출하여 얻을 수 있습니다. 그러나 impl 블록은 impl 바깥에 있을 경우 해제해야 할 수도 있는 제네릭 매개변수를 바인딩하므로, 이 쿼리는 EarlyBinder<Ty>를 반환합니다.
EarlyBinder 타입은 “이미 바인더 내부에 있을 때” 바인더를 해제하기 위한 instantiate_identity 함수를 제공합니다. 이는 사실상 EarlyBinder::instantiate(GenericArgs::identity_for_item(..))를 작성하는 것의 더 성능이 좋은 버전입니다. 개념적으로 이는 루트 유니버스의 플레이스홀더로 바인더를 인스턴스화하여 바인더를 해제합니다(이것이 무엇을 의미하는지는 다음 몇 장에서 다루겠습니다). 하지만 실제로는 아무런 수정 없이 내부 값을 그대로 반환할 뿐입니다.
Binder와 상위 랭크 영역
때로는 제네릭 매개변수를 아이템이 아니라 타입이나 where 절의 일부로 정의하기도 합니다. 예를 들어, 타입 for<'a> fn(&'a u32)와 where 절 for<'a> T: Trait<'a>는 모두 'a라는 이름의 제네릭 라이프타임을 도입합니다. 현재 for<T>나 for<const N: usize>에 대한 안정화된 구문은 없지만, nightly에서는 feature(non_lifetime_binders)를 사용하여 for<T>/for<const N: usize>를 사용하는 where 절을 작성할 수 있습니다(타입은 작성할 수 없습니다).
for는 새로운 이름을 스코프에 도입하기 때문에 “바인더(binder)“라고 부릅니다. rustc에서는 Binder 타입을 사용하여 이러한 매개변수가 어디에서 도입되는지와 매개변수가 무엇인지(즉, 몇 개인지, 그리고 각 매개변수가 타입/상수/영역 중 무엇인지)를 추적합니다. for<'a> fn(&'a u32)와 같은 타입은 rustc에서 다음과 같이 표현됩니다.
Binder(
fn(&RegionKind::Bound(DebruijnIndex(0), BoundVar(0)) u32) -> (),
&[BoundVariableKind::Region(...)],
)
이러한 매개변수의 사용은 RegionKind::Bound(또는 TyKind::Bound/ConstKind::Bound 배리언트)로 표현됩니다. 이러한 바운드 영역/타입/상수는 두 가지 주요 데이터로 구성됩니다.
- 어느 바인더를 가리키는지 지정하는 DebruijnIndex입니다.
Binder가 도입하는 매개변수 중 어느 것을 가리키는지 지정하는BoundVar입니다.
또한 진단 목적으로 BoundTyKind/BoundRegionKind를 통해 추가 정보를 저장하기도 하지만, 이는 타입 동등성이나 더 일반적으로는 Ty의 의미론에 중요하지 않습니다. (위 예시에서는 생략했습니다)
디버그 출력에서는(그리고 서로 비공식적으로 이야기할 때도) 이러한 바운드 변수를 ^DebruijnIndex_BoundVar 형식으로 쓰는 경향이 있습니다. 위 예시는 이 형식으로 쓰면 Binder(fn(&'^0_0), &[BoundVariableKind::Region])가 됩니다. DebruijnIndex가 0일 때는 이를 생략하고 ^0이라고 쓰기도 합니다.
또 다른 구체적인 예시로, 이번에는 where 절과 타입에 for<'a>가 섞여 있는 경우입니다.
where
for<'a> Foo<for<'b> fn(&'a &'b T)>: Trait,
이는 다음과 같이 표현됩니다.
Binder(
Foo<Binder(
fn(&'^1_0 &'^0 T/#0),
[BoundVariableKind::Region(...)]
)>: Trait,
[BoundVariableKind::Region(...)]
)
'^1_0이 'a 매개변수를 가리킨다는 점에 주목하십시오. 가장 안쪽 바인더에서 한 단계 위의 바인더를 가리키기 위해 1인 DebruijnIndex를 사용하고, 첫 번째로 바인딩된 매개변수인 'a를 가리키기 위해 0인 var를 사용합니다. 또한 'b 매개변수를 가리키기 위해 '^0을 사용하는데, DebruijnIndex가 0(가장 안쪽 바인더를 가리킴)이므로 이를 생략하여, 첫 번째로 바인딩된 매개변수인 'b를 가리키는 boundvar 0만 남깁니다.
예전에는 각 Binder가 도입하는 바운드 변수의 집합을 항상 명시적으로 추적하지는 않았으며, 이로 인해 여러 버그가 발생했습니다(구체적으로는 ICE #81193, #79949, #83017). 이를 명시적으로 추적함으로써, 상위 랭크 where 절/타입을 구성할 때 탈출하는 바운드 변수나 다른 바인더의 변수가 없다는 것을 단언할 수 있습니다. 바인더 내부에 잘못된 타입이 있는 다음 예시를 보십시오.
Binder(
fn(&'^1_0 &'^1 T/#0),
&[BoundVariableKind::Region(...)],
)
'^1_0 영역은 가장 바깥쪽 바인더보다 더 높은 수준의 바인더를 가리키므로, 즉 탈출하는 바운드 변수이므로 온갖 문제를 일으킵니다. '^1 영역('^0_1로도 쓸 수 있음) 역시 그것이 가리키는 바인더가 두 번째 매개변수를 도입하지 않으므로 잘못된 형태입니다. 오늘날의 rustc는 이 두 가지 이유로 이 바인더를 구성할 때 ICE를 일으킵니다. 과거에는 이것이 그냥 동작하도록 허용했다가 코드베이스의 다른 부분에서 문제를 겪었을 것입니다.
Binder 인스턴스화하기
EarlyBinder와 마찬가지로, Binder의 내부에 접근할 때는 먼저 바운드 변수를 다른 값으로 치환하여 바인더를 해제해야 합니다. 이는 EarlyBinder의 경우와 거의 같은 이유 때문이며, Binder가 도입한 매개변수를 참조하는 타입은 해당 바인더 밖에서는 아무런 의미가 없습니다. 다음의 오류가 발생하는 예제를 보십시오.
fn foo<'a>(a: &'a u32) -> &'a u32 {
a
}
fn bar<T>(a: fn(&u32) -> T) -> T {
a(&10)
}
fn main() {
let higher_ranked_fn_ptr = foo as for<'a> fn(&'a u32) -> &'a u32;
// Attempt to infer `T=for<'a> &'a u32` which is not satsifiable
let references_bound_vars = bar(higher_ranked_fn_ptr);
}
이 예제에서는 for<'a> fn(&'^0 u32) -> &'^0 u32 타입의 인자를 bar에 전달하고 있습니다. T가 &'^0 u32 타입으로 추론되도록 허용해서는 안 되는데, 그렇게 되면 상당히 무의미하기 때문입니다(그리고 우연히 ICE가 발생하지 않는다면 아마도 건전하지 않을 것입니다). main은 'a에 대해 알지 못하므로 빌림 검사기는 라이프타임 'a를 가진 빌림을 처리할 수 없을 것입니다.
EarlyBinder와 달리, 일반적으로 Binder는 사용자로부터 받은 구체적인 인자 집합으로 인스턴스화하지 않습니다. 예를 들어 for<'a1, 'a2> fn(&'a1 u32, &'a2 u32)에 대한 인자로 ['b, 'static]을 사용하는 식으로 인스턴스화하지 않습니다. 대신 보통은 추론 변수나 플레이스홀더로 바인더를 인스턴스화합니다.
추론 변수로 인스턴스화하기
바인더의 가능한 인스턴스화 하나를 추론하려고 할 때, 예를 들어 고차 함수 포인터를 호출하거나 고차 where 절을 사용해 어떤 바운드를 증명하려고 할 때 추론 변수로 바인더를 인스턴스화합니다. 예를 들어, 위 예제의 higher_ranked_fn_ptr이 주어졌을 때 이를 &10_u32로 호출한다면 다음과 같이 진행합니다.
- 추론 변수로 바인더를 인스턴스화하여
fn(&'?0 u32) -> &'?0 u32)시그니처를 얻습니다 - 전달된 인자
&10_u32의 타입(&’static u32)을 시그니처의 타입인&'?0 u32와 동일시하여'?0 = 'static을 추론합니다 - 전달된 인자의 타입을 함수 포인터 시그니처에 있는 인자의 타입과 성공적으로 단일화할 수 있었으므로, 전달된 인자는 올바릅니다
추론 변수로 인스턴스화하는 또 다른 예로, for<'a> T: Trait<'a> where 절이 주어졌을 때 T: Trait<'static>이 성립함을 증명하려 한다면 다음과 같이 진행합니다.
- 추론 변수로 바인더를 인스턴스화하여
T: Trait<'?0>where 절을 얻습니다 T: Trait<'static>목표를 인스턴스화된 where 절과 동일시하여'?0 = 'static을 추론합니다T: Trait<'static>을T: Trait<'?0>과 성공적으로 단일화할 수 있었으므로 목표가 성립합니다
추론 변수로 바인더를 인스턴스화하려면 InferCtxt의 instantiate_binder_with_fresh_vars 메서드를 사용하면 됩니다. 바인더의 특정한 인스턴스화 하나에만 관심이 있을 때는 추론 변수로 바인더를 인스턴스화해야 하며, 그 대신 바인더의 가능한 모든 인스턴스화에 대해 추론하고자 한다면 플레이스홀더를 사용해야 합니다.
플레이스홀더로 인스턴스화하기
플레이스홀더는 Ty/ConstKind::Param/ReEarlyParam과 매우 비슷하며, 자기 자신과만 같은 어떤 알 수 없는 타입을 나타냅니다. Ty/Const와 Region은 모두 Universe와 BoundVar로 구성된 Placeholder 변형을 가지고 있습니다.
Universe는 플레이스홀더가 어느 바인더에서 비롯되었는지를 추적하고, BoundVar는 이 플레이스홀더가 해당 바인더의 어느 매개변수에 대응하는지를 추적합니다. 플레이스홀더의 동등성은 오직 유니버스가 같은지와 BoundVar가 같은지에 의해서만 결정됩니다. 자세한 내용은 플레이스홀더와 유니버스에 관한 장을 참조하십시오.
다른 rustc 개발자와 이야기하거나 Debug 형식으로 출력된 Ty/Const/Region을 볼 때, Placeholder는 흔히 '!UNIVERSE_BOUNDVARS로 표기됩니다. 예를 들어, for<'a> fn(&'a u32, for<'b> fn(&'b &'a u32)) 타입이 주어졌을 때 두 바인더를 모두 인스턴스화하고 나면(현재 InferCtxt의 Universe가 그 전에 U0이었다고 가정합니다), &'b &'a u32의 타입은 &'!2_0 &!1_0 u32로 표현됩니다.
플레이스홀더의 유니버스가 0이면 디버그 출력에서 완전히 생략됩니다. 즉, !0_2는 !2로 출력됩니다. 하지만 플레이스홀더로 바인더를 인스턴스화할 때 InferCtxt의 유니버스를 증가시키므로 실제로는 이런 경우가 드물며, 보통 마주칠 수 있는 가장 낮은 유니버스의 플레이스홀더는 U1에 있는 것입니다.
Binder는 InferCtxt의 enter_forall 메서드를 통해 플레이스홀더로 인스턴스화할 수 있습니다. 컴파일러가 바인더의 구체적인 인스턴스화 하나가 아니라 가능한 모든 인스턴스화를 고려해야 할 때는 언제나 이 메서드를 사용해야 합니다.
참고: 이 장의 원래 예제에서는 지역 변수의 타입을 &'^0 u32로 추론해서는 안 된다고 언급했습니다. 이 코드는 유니버스를 통해 컴파일되지 않도록 막힙니다(링크된 장에서 설명한 것과 같습니다).
RePlaceholder와 ReBound가 둘 다 있는 이유는 무엇입니까?
이 두 변형이 왜 모두 있는지 궁금하실 수 있습니다. 결국 Placeholder에 저장되는 데이터는 사실상 ReBound의 데이터와 같기 때문입니다. 즉, 어떤 바인더인지 추적하는 정보와, Binder가 도입한 매개변수 중 어느 것인지 추적하는 인덱스입니다.
그 주된 이유는 Bound가 바운드 변수의 보다 구문적인 표현인 반면, Placeholder는 보다 의미적인 표현이기 때문입니다. 구체적인 예는 다음과 같습니다.
#![allow(unused)]
fn main() {
impl<'a> Other<'a> for &'a u32 { }
impl<T> Trait for T
where
for<'a> T: Other<'a>,
{ ... }
impl<T> Bar for T
where
for<'a> &'a T: Trait
{ ... }
}
이러한 트레이트 구현이 주어졌을 때 u32: Bar는 성립하지 않아야 합니다. &'a u32는 빌림의 라이프타임과 트레이트의 라이프타임이 같을 때에만 Other<'a>를 구현합니다. 그러나 ReBound만 사용하고 플레이스홀더가 없었다면, 그 트레이트 바운드가 성립한다고 실수로 믿기 쉬웠을 것입니다. 이를 설명하기 위해 rustc에 플레이스홀더가 없는 세계에서 u32: Bar를 증명하려는 예를 차례로 살펴봅시다.
- 먼저
u32: Bar를 증명하려고 시도합니다. impl<T> Bar for Timpl을 찾게 되고, 결국EarlyBinder를u32로 인스턴스화하게 됩니다(참고: 실제로는 먼저 바인더를 추론 변수로 인스턴스화한 뒤 그 추론 변수를u32로 추론하므로 이 설명이 완전히 정확하지는 않지만, 여기서 그 차이는 그다지 중요하지 않습니다).- impl에는
for<'a> &'^0 T: Trait라는 where 절이 있으며, 초기 바인더를u32로 인스턴스화했으므로 실제로는for<'a> &'^0 u32: Trait를 증명해야 합니다. impl<T> Trait for Timpl을 찾게 되고, 결국EarlyBinder를&'^0 u32로 인스턴스화하게 됩니다.for<'a> T: Other<'^0>라는 where 절이 있으며, 초기 바인더를&'^0 u32로 인스턴스화했으므로 실제로는for<'a> &'^0 u32: Other<'^0>를 증명해야 합니다.impl<'a> Other<'a> for &'a u32를 찾게 되며, 빌림의 라이프타임과 트레이트의 라이프타임이 모두'^0이므로 이 impl만으로 바운드를 증명하기에 충분합니다.
이 최종 결과는 잘못되었습니다. 각자 고유한 제네릭 매개변수를 도입하는 별개의 바인더가 두 개 있었으므로, 트레이트 바운드는 for<'a1, 'a2> &'^1 u32: Other<'^0>와 같은 형태가 되었어야 하며, 이는 impl<'a> Other<'a> for &'a u32로 충족되지 않습니다.
이론적으로는 이것이 동작하도록 만들 수 있지만, 상당히 번거롭고 현재 구성보다 복잡해질 것입니다. 다음과 같은 작업이 필요합니다.
Binder/EarlyBinder를Boundty/const/region으로 인스턴스화할 때마다 바운드 변수가 더 높은DebruijnIndex를 갖도록 “다시 작성“합니다- 추론 변수를 바운드 변수로 추론할 때, 그 바운드 변수가 추론 변수를 생성한 이후에 진입한 바인더에서 온 것이라면 해당 변수의
DebruijnIndex를 낮춰야 합니다. - 추론 변수가 어떤 바인더 안에서 생성되었는지, 그리고 매개변수를 지칭할 수 있는 가장 안쪽 바인더가 무엇인지를 별도로 추적해야 합니다(현재는 후자만 추적하면 됩니다).
- 추론 변수를 해석할 때 infcx의 현재 바인더 깊이에 따라 모든 바운드 변수를 다시 작성합니다
- 이 밖에도 더 있을 수 있습니다(이 목록을 작성하는 동안 항목이 계속 추가되었으므로, 이것이 전부라고 생각하는 것은 순진해 보입니다).
근본적으로 이 모든 복잡성은 Bound 타입/상수/리전이 Binder의 특정 매개변수에 대해, 그 매개변수를 도입한 바인더와 매개변수가 사용되는 위치 사이에 다른 Binder가 몇 개 있는지에 따라 서로 다른 표현을 갖기 때문에 생깁니다. 예를 들어, 다음 코드가 있다고 합시다.
#![allow(unused)]
fn main() {
fn foo<T>()
where
for<'a> T: Trait<'a, for<'b> fn(&'b T, &'a u32)>
{ ... }
}
이 where 절은 for<'a> T: Trait<'^0, for<'b> fn(&'^0 T, &'^1_0 u32)>로 작성됩니다. 'a 매개변수에 대한 참조가 두 개 있지만, 뒤쪽의 사용이 내부 함수 포인터 타입에 대한 두 번째 Binder 아래에 중첩되어 있기 때문에 두 참조는 각각 ^0과 ^1_0으로 서로 다르게 표현됩니다.
반면 Placeholder 타입/상수/리전에는 이러한 제약이 없습니다. Universe는 매개변수의 사용 위치가 아니라 현재 InferCtxt에 고유하기 때문입니다.
Placeholder는 어떤 문맥에 있든 동일한 표현을 가지므로, EarlyBinder를 인스턴스화하거나 추론 변수를 기존 Placeholder와 단일화하는 일은 아주 간단하게 할 수 있습니다. 예를 들어, 위의 고랭크(higher ranked) where 절의 바인더를 인스턴스화하면 T: Trait<'!1_0, for<'b> fn(&'^0 T, &'!1_0 u32)>와 같이 표현됩니다. 둘 중 하나가 다른 Binder 아래에 있는데도 'a의 두 사용에 대한 RePlaceholder 표현은 동일합니다.
이어서 함수 포인터의 바인더를 인스턴스화하면 fn(&'!2_0 T, ^'!1_0 u32)와 같은 타입을 얻게 되며, 'b 매개변수에 대한 RePlaceholder는 그 바인더가 'a의 바인더보다 나중에 인스턴스화되었다는 사실을 추적하기 위해 더 높은 유니버스에 있습니다.
ReLateParam으로 인스턴스화하기
타입 표현에 관한 장에서 설명했듯이, RegionKind에는 제네릭 매개변수를 표현하기 위한 두 가지 변형인 ReLateParam과 ReEarlyParam이 있습니다. ReLateParam은 개념적으로 항상 루트 유니버스(U0)에 있는 Placeholder입니다. 이는 함수/클로저 내부에 있는 동안 해당 함수/클로저의 후기 바인딩 매개변수를 인스턴스화할 때 사용됩니다. 실제 표현은 ReEarlyParam과도, RePlaceholder와도 상당히 다릅니다:
- 후기 바인딩 제네릭 매개변수를 도입한 항목의
DefId - 제네릭 매개변수의
DefId와 (Symbol을 통한) 그 이름을 지정하거나, 이 플레이스홀더가Fn/FnMut클로저의 self 빌림에 대한 익명 라이프타임을 나타낸다는 것을 지정하는BoundRegionKindBrAnon에 대한 변형도 있지만, 이는ReLateParam에는 사용되지 않습니다.
예를 들어, 다음 코드가 있다고 합시다.
impl Trait for Whatever {
fn foo<'a>(a: &'a u32) -> &'a u32 {
let b: &'a u32 = a;
b
}
}
함수 본문에 있는 &'a u32 타입의 라이프타임 'a는 다음과 같이 표현됩니다:
ReLateParam(
{impl#0}::foo,
BoundRegionKind::BrNamed({impl#0}::foo::'a, "'a")
)
함수 본문 내부에서 그 함수의 후기 바인딩 제네릭 매개변수를 참조하는 이 특정한 경우에는, 어딘가에서 Binder를 인스턴스화할 때 명시적으로 수행되는 것이 아니라 hir_ty_lowering 중에 암묵적으로 수행됩니다. 그러나 경우에 따라서는 Binder를 ReLateParam으로 명시적으로 인스턴스화하기도 합니다.
일반적으로 함수/클로저의 후기 바인딩 매개변수에 대한 Binder가 있고 개념적으로 이미 그 바인더 내부에 있을 때는, liberate_late_bound_regions를 사용하여 이를 ReLateParam으로 인스턴스화합니다. 따라서 이 연산은 EarlyBinder의 instantiate_identity에 대응하는 Binder의 연산이 됩니다.
구체적인 예로, 타입 검사 중인 함수의 시그니처에 접근하면 EarlyBinder<Binder<FnSig>>로 표현됩니다. 이미 이 바인더들의 “내부“에 있으므로, instantiate_identity를 호출한 뒤 liberate_late_bound_regions를 호출하게 됩니다.
조기 바인딩 매개변수와 후기 바인딩 매개변수
NOTE: 이 장에서는 주로 조기/후기 바인딩을 함수 항목 타입/함수 정의를 논의할 때에만 관련된 것처럼 다룹니다. 이는 완전히 옳지 않을 수 있으며, async 블록과 클로저도 이 장에서 어느 정도 다루어야 할 것입니다.
조기 바인딩 매개변수와 후기 바인딩 매개변수의 구분이 도입되었을 당시의 다음 블로그 글도 참고하십시오: Intermingled parameter lists와 Intermingled parameter lists, take 2.
“조기” 바인딩되거나 “지연” 바인딩된다는 것은 무엇을 의미합니까
모든 함수 정의에는 Fn* 트레이트를 구현하는 대응 ZST가 있으며, 이를 함수 항목 타입이라고 합니다. 이 부분에서는 함수 항목 타입의 “디슈가링“에 대해 조금 다룹니다. 이는 조기 바인딩 제네릭 매개변수와 후기 바인딩 제네릭 매개변수의 차이를 설명하는 데 유용한 맥락이 되기 때문입니다.
제네릭 매개변수가 없는 아주 단순한 예제부터 시작합시다:
#![allow(unused)]
fn main() {
fn foo(a: String) -> u8 {
1
/* snip */
}
}
foo에 대응하는 함수 항목 타입과 그에 연관된 Fn impl의 정의를 명시적으로 작성하면 대략 다음과 같습니다:
struct FooFnItem;
impl Fn<(String,)> for FooFnItem {
type Output = u8;
/* fn call(&self, ...) -> ... { ... } */
}
FnMut/FnOnce 트레이트에 대한 내장 impl과 Copy 및 Clone에 대한 impl은 간결성을 위해 생략했습니다(물론 이 트레이트들은 함수 항목 타입에 대해 실제로 구현되어 있습니다).
조금 더 복잡한 예제로, 함수에 제네릭 매개변수를 도입해 보겠습니다:
#![allow(unused)]
fn main() {
fn foo<T: Sized>(a: T) -> T {
a
/* snip */
}
}
정의를 작성하면 대략 다음과 같습니다:
struct FooFnItem<T: Sized>(PhantomData<fn(T) -> T>);
impl<T: Sized> Fn<(T,)> for FooFnItem<T> {
type Output = T;
/* fn call(&self, ...) -> ... { ... } */
}
함수 항목 타입 FooFnItem은 함수 foo에 정의된 대로 어떤 타입 매개변수 T에 대해 제네릭하다는 점에 유의하십시오. 그러나 다음 예제에서 보듯이, 함수에 정의된 제네릭 매개변수가 모두 함수 항목 타입에도 정의되는 것은 아닙니다:
#![allow(unused)]
fn main() {
fn foo<'a, T: Sized>(a: &'a T) -> &'a T {
a
/* snip */
}
}
이 예제를 “디슈가링“한 형태는 다음과 같습니다:
struct FooFnItem<T: Sized>(PhantomData<for<'a> fn(&'a T) -> &'a T>);
impl<'a, T: Sized> Fn<(&'a T,)> for FooFnItem<T> {
type Output = &'a T;
/* fn call(&self, ...) -> ... { ... } */
}
함수 foo의 라이프타임 매개변수 'a는 함수 항목 타입 FooFnItem에 존재하지 않으며, 대신 인자 타입을 표현하는 용도로만 내장 impl에 도입됩니다.
제네릭 매개변수가 모두 함수 항목 타입에 정의되지는 않으므로, 함수를 호출할 때 제네릭 인자는 두 단계에 걸쳐 제공됩니다.
- 함수를 이름으로 참조할 때(예:
let a = foo;)FooFnItem에 대한 인자가 제공됩니다. - 함수를 호출할 때(예:
a(&10);) 내장 impl에 정의된 매개변수가 모두 제공됩니다.
조기/후기라는 명명 방식은 이 2단계 체계에서 유래했습니다. 조기 바인딩 매개변수는 가장 이른 단계(함수 이름 지정)에서 제공되고, 후기 바인딩 매개변수는 가장 늦은 단계(함수 호출)에서 제공됩니다.
앞의 예제의 디슈가링을 살펴보면, T는 함수 항목 타입에 나타나지만 'a는 나타나지 않으므로 T는 조기 바인딩 타입 매개변수이고 'a는 후기 바인딩 라이프타임 매개변수임을 알 수 있습니다. 각 제네릭 매개변수의 인자가 어디에서 제공되는지 주석으로 표시한, foo를 호출하는 다음 예제를 참고하십시오:
#![allow(unused)]
fn main() {
fn foo<'a, T: Sized>(a: &'a T) -> &'a T {
a
/* snip */
}
// Here we provide a type argument `String` to the
// type parameter `T` on the function item type
let my_func = foo::<String>;
// Here (implicitly) a lifetime argument is provided
// to the lifetime parameter `'a` on the builtin impl.
my_func(&String::new());
}
조기 바인딩 매개변수와 후기 바인딩 매개변수의 차이
고차 함수 포인터와 트레이트 바운드
제네릭 매개변수가 후기 바인딩이면 함수 항목을 더 유연하게 사용할 수 있습니다. 예를 들어, 조기 바인딩 라이프타임 매개변수를 가진 함수 foo와 후기 바인딩 라이프타임 매개변수 'a를 가진 함수 bar가 있다면, 다음과 같은 내장 Fn impl이 존재하게 됩니다:
impl<'a> Fn<(&'a String,)> for FooFnItem<'a> { /* ... */ }
impl<'a> Fn<(&'a String,)> for BarFnItem { /* ... */ }
bar 함수의 함수 항목 타입은 어떤 라이프타임의 빌림으로도 호출할 수 있으므로 bar의 시그니처가 엄밀히 더 유연합니다. 반면 foo 함수 항목 타입은 함수 항목 타입에 있는 것과 같은 라이프타임의 빌림으로만 호출할 수 있습니다. foo의 함수 항목 타입을 서로 다른 라이프타임으로 여러 번 호출해 보기만 해도 이를 확인할 수 있습니다:
#![allow(unused)]
fn main() {
// The `'a: 'a` bound forces this lifetime to be early bound.
fn foo<'a: 'a>(b: &'a String) -> &'a String { b }
fn bar<'a>(b: &'a String) -> &'a String { b }
// Early bound generic parameters are instantiated here when naming
// the function `foo`. As `'a` is early bound an argument is provided.
let f = foo::<'_>;
// Both function arguments are required to have the same lifetime as
// the lifetime parameter being early bound means that `f` is only
// callable for one specific lifetime.
//
// As we call this with borrows of different lifetimes, the borrow checker
// will error here.
f(&String::new());
f(&String::new());
}
foo의 라이프타임 매개변수가 조기 바인딩되어 있으므로 f의 모든 호출자는 같은 라이프타임의 빌림을 제공해야 합니다. 이 예제에서는 foo의 함수 항목 타입을 두 번 호출하며, 매번 임시 값의 빌림을 전달합니다. 임시 값은 함수 호출 동안에만 살아 있고 그 이후에는 살아 있지 않으므로 이 두 빌림의 라이프타임은 겹칠 수 없으며, 따라서 컴파일 오류가 발생합니다.
foo의 라이프타임 매개변수가 후기 바인딩(late bound)이었다면, 각 호출자가 자신의 빌림에 대해 서로 다른 라이프타임 인자를 제공할 수 있으므로 이 코드는 컴파일될 수 있었을 것입니다. 위에서 정의한 bar 함수를 사용하여 이를 보여 주는 다음 예제를 참고하십시오:
#![allow(unused)]
fn main() {
fn foo<'a: 'a>(b: &'a String) -> &'a String { b }
fn bar<'a>(b: &'a String) -> &'a String { b }
// Early bound parameters are instantiated here, however as `'a` is
// late bound it is not provided here.
let b = bar;
// Late bound parameters are instantiated separately at each call site
// allowing different lifetimes to be used by each caller.
b(&String::new());
b(&String::new());
}
이는 함수 항목 타입을 고차 함수 포인터로 강제 변환하고 고차 Fn 트레이트 바운드를 증명할 수 있는 능력에 반영됩니다. 다음 예제로 이를 보여 줄 수 있습니다:
#![allow(unused)]
fn main() {
// The `'a: 'a` bound forces this lifetime to be early bound.
fn foo<'a: 'a>(b: &'a String) -> &'a String { b }
fn bar<'a>(b: &'a String) -> &'a String { b }
fn accepts_hr_fn(_: impl for<'a> Fn(&'a String) -> &'a String) {}
fn higher_ranked_trait_bound() {
let bar_fn_item = bar;
accepts_hr_fn(bar_fn_item);
let foo_fn_item = foo::<'_>;
// errors
accepts_hr_fn(foo_fn_item);
}
fn higher_ranked_fn_ptr() {
let bar_fn_item = bar;
let fn_ptr: for<'a> fn(&'a String) -> &'a String = bar_fn_item;
let foo_fn_item = foo::<'_>;
// errors
let fn_ptr: for<'a> fn(&'a String) -> &'a String = foo_fn_item;
}
}
두 경우 모두 빌림 검사기는 foo_fn_item을 임의의 라이프타임을 가진 빌림으로 호출할 수 있다고 보지 않으므로 오류를 발생시킵니다. 이는 foo의 라이프타임 매개변수가 조기 바인딩(early bound)되어 있어 foo_fn_item이 FooFnItem<'_> 타입을 갖게 되기 때문이며, 이 타입은 (디슈가링된 Fn impl에서 보이듯이) 동일한 라이프타임 '_를 가진 빌림으로만 호출할 수 있습니다.
후기 바인딩 매개변수가 있을 때의 터보피시
앞서 언급했듯이, 조기 바인딩 매개변수와 후기 바인딩 매개변수의 구분은 제네릭 매개변수가 인스턴스화되는 지점이 두 군데라는 것을 의미합니다:
- 함수의 이름을 지정할 때(조기)
- 함수를 호출할 때(후기)
현재 호출 단계에서 후기 바인딩 매개변수에 대한 제네릭 인자를 명시적으로 지정하는 구문은 없습니다. 제네릭 인자는 함수 이름을 지정할 때 조기 바인딩 매개변수에 대해서만 지정할 수 있습니다. foo::<'static>(); 구문은 함수 호출의 일부임에도 불구하고 (foo::<'static>)();처럼 동작하며, 함수 항목 타입의 조기 바인딩 제네릭 매개변수를 인스턴스화합니다.
다음 예제를 참고하십시오:
#![allow(unused)]
fn main() {
fn foo<'a>(b: &'a u32) -> &'a u32 { b }
let f /* : FooFnItem<????> */ = foo::<'static>;
}
위 예제는 라이프타임 매개변수 'a가 후기 바인딩되어 있어 “함수 이름 지정” 단계에서 인스턴스화될 수 없으므로 오류가 발생합니다. 라이프타임 매개변수를 조기 바인딩으로 만들면 이 코드가 컴파일되기 시작하는 것을 볼 수 있습니다:
#![allow(unused)]
fn main() {
fn foo<'a: 'a>(b: &'a u32) -> &'a u32 { b }
let f /* : FooFnItem<'static> */ = foo::<'static>;
}
현재 컴파일러 구현이 의도하는 바는 조기 바인딩 라이프타임 매개변수와 후기 바인딩 라이프타임 매개변수를 모두 가진 함수에 라이프타임 인자를 지정할 때 오류를 발생시키는 것입니다. 실제로는 기존 코드가 지나치게 많이 깨지는 문제 때문에, 일부 경우는 향후 호환성 경고(future compatibility warning)에 그칩니다(#42868):
- 라이프타임 인자의 개수가 조기 바인딩 라이프타임 매개변수의 개수와 같을 때는 오류 대신 FCW가 발생합니다
- 메서드 호출 구문을 사용할 때는 오류가 항상 FCW로 격하됩니다
이를 보여 주기 위해 여러 종류의 함수를 작성하고 각 함수에 후기 바인딩 라이프타임과 조기 바인딩 라이프타임을 하나씩 부여할 수 있습니다:
fn free_function<'a: 'a, 'b>(_: &'a (), _: &'b ()) {}
struct Foo;
trait Trait: Sized {
fn trait_method<'a: 'a, 'b>(self, _: &'a (), _: &'b ());
fn trait_function<'a: 'a, 'b>(_: &'a (), _: &'b ());
}
impl Trait for Foo {
fn trait_method<'a: 'a, 'b>(self, _: &'a (), _: &'b ()) {}
fn trait_function<'a: 'a, 'b>(_: &'a (), _: &'b ()) {}
}
impl Foo {
fn inherent_method<'a: 'a, 'b>(self, _: &'a (), _: &'b ()) {}
fn inherent_function<'a: 'a, 'b>(_: &'a (), _: &'b ()) {}
}
그런 다음 첫 번째 경우에 대해 각 함수를 (하나뿐인 조기 바인딩 라이프타임 매개변수에 대응하는) 라이프타임 인자 하나로 호출해 보면, 하드 오류가 아니라 FCW만 발생한다는 것을 확인할 수 있습니다.
#![allow(unused)]
#![deny(late_bound_lifetime_arguments)]
fn main() {
fn free_function<'a: 'a, 'b>(_: &'a (), _: &'b ()) {}
struct Foo;
trait Trait: Sized {
fn trait_method<'a: 'a, 'b>(self, _: &'a (), _: &'b ());
fn trait_function<'a: 'a, 'b>(_: &'a (), _: &'b ());
}
impl Trait for Foo {
fn trait_method<'a: 'a, 'b>(self, _: &'a (), _: &'b ()) {}
fn trait_function<'a: 'a, 'b>(_: &'a (), _: &'b ()) {}
}
impl Foo {
fn inherent_method<'a: 'a, 'b>(self, _: &'a (), _: &'b ()) {}
fn inherent_function<'a: 'a, 'b>(_: &'a (), _: &'b ()) {}
}
// Specifying as many arguments as there are early
// bound parameters is always a future compat warning
Foo.trait_method::<'static>(&(), &());
Foo::trait_method::<'static>(Foo, &(), &());
Foo::trait_function::<'static>(&(), &());
Foo.inherent_method::<'static>(&(), &());
Foo::inherent_function::<'static>(&(), &());
free_function::<'static>(&(), &());
}
두 번째 경우에 대해서는 각 함수를 (조기 바인딩이든 후기 바인딩이든) 라이프타임 매개변수 개수보다 많은 라이프타임 인자로 호출해 보면, 자유 함수와 연관 함수는 하드 오류가 발생하는 반면 메서드 호출은 FCW가 발생한다는 것을 확인할 수 있습니다:
#![allow(unused)]
fn main() {
fn free_function<'a: 'a, 'b>(_: &'a (), _: &'b ()) {}
struct Foo;
trait Trait: Sized {
fn trait_method<'a: 'a, 'b>(self, _: &'a (), _: &'b ());
fn trait_function<'a: 'a, 'b>(_: &'a (), _: &'b ());
}
impl Trait for Foo {
fn trait_method<'a: 'a, 'b>(self, _: &'a (), _: &'b ()) {}
fn trait_function<'a: 'a, 'b>(_: &'a (), _: &'b ()) {}
}
impl Foo {
fn inherent_method<'a: 'a, 'b>(self, _: &'a (), _: &'b ()) {}
fn inherent_function<'a: 'a, 'b>(_: &'a (), _: &'b ()) {}
}
// Specifying more arguments than there are early
// bound parameters is a future compat warning when
// using method call syntax.
Foo.trait_method::<'static, 'static, 'static>(&(), &());
Foo.inherent_method::<'static, 'static, 'static>(&(), &());
// However, it is a hard error when not using method call syntax.
Foo::trait_method::<'static, 'static, 'static>(Foo, &(), &());
Foo::trait_function::<'static, 'static, 'static>(&(), &());
Foo::inherent_function::<'static, 'static, 'static>(&(), &());
free_function::<'static, 'static, 'static>(&(), &());
}
후기 바인딩 라이프타임 매개변수와 조기 바인딩 라이프타임 매개변수 모두에 충분한 라이프타임 인자를 지정하더라도, 이 인자들은 실제로 후기 바인딩 매개변수에 제공되는 라이프타임을 표기하는 데 사용되지 않습니다. 정적이지 않은 빌림을 제공하면서 함수에 'static을 터보피시로 지정해 보면 이를 확인할 수 있습니다:
#![allow(unused)]
fn main() {
struct Foo;
impl Foo {
fn inherent_method<'a: 'a, 'b>(self, _: &'a (), _: &'b String ) {}
}
Foo.inherent_method::<'static, 'static>(&(), &String::new());
}
&String::new() 함수 인자가 'static 라이프타임을 갖지 않는데도 이 코드는 컴파일되는데, 이는 실제로 함수를 호출할 때 “여분의” 라이프타임 인자가 후기 바인딩 매개변수에 반영되지 않고 버려지기 때문입니다.
후기 바인딩 매개변수를 가진 타입의 활성(liveness)
함수 항목 타입과 관련된 타입 outlives 바운드를 검사할 때는 조기 바인딩 매개변수를 고려합니다. 예를 들면 다음과 같습니다.
#![allow(unused)]
fn main() {
fn foo<T>(_: T) {}
fn requires_static<T: 'static>(_: T) {}
fn bar<T>() {
let f /* : FooFnItem<T> */ = foo::<T>;
requires_static(f);
}
}
타입 매개변수 T는 조기 바인딩이므로, foo에 대한 함수 항목 타입의 디슈가링은 struct FooFnItem<T>와 비슷한 형태가 됩니다. 그러면 FooFnItem<T>: 'static이 성립하려면 T: 'static도 성립하도록 요구해야 하며, 그렇지 않으면 건전성(soundness) 버그가 생기게 됩니다.
안타깝게도 컴파일러의 버그 때문에 조기 바인딩 라이프타임은 고려되지 않으며, 이것이 아직 해결되지 않은 건전성 버그 #84366의 원인입니다. 이는 후기 바인딩될 수 있는 유일한 종류의 제네릭 매개변수가 잘못 처리되고 있는 라이프타임뿐이므로, 활성/타입 outlives 바운드에 대해 조기 바인딩 매개변수와 후기 바인딩 매개변수 사이의 “차이“를 보여 주는 것이 불가능하다는 것을 의미합니다.
어쨌든 이론상으로는 #84366이 수정되면 아래 코드 예제가 그러한 차이를 보여 주어야 합니다:
#![allow(unused)]
fn main() {
fn early_bound<'a: 'a>(_: &'a String) {}
fn late_bound<'a>(_: &'a String) {}
fn requires_static<T: 'static>(_: T) {}
fn bar<'b>() {
let e = early_bound::<'b>;
// this *should* error but does not
requires_static(e);
let l = late_bound;
// this correctly does not error
requires_static(l);
}
}
매개변수가 후기 바인딩이 되기 위한 요건
라이프타임 매개변수여야 합니다
dyn for<T> Fn(Box<T>)나 for<T> fn(Box<T>) 같은 타입을 지원할 방법이 없으므로, 타입 매개변수와 상수 매개변수는 후기 바인딩이 될 수 없습니다. 이러한 타입을 호출하려면 기반 함수를 단형화(monomorphize)할 수 있어야 하는데, 동적 디스패치를 통한 간접 호출로는 이것이 불가능합니다.
where 절에서 사용되어서는 안 됩니다
현재 제네릭 매개변수가 where 절에 사용되면 그 매개변수는 반드시 조기 바인딩이어야 합니다. 예를 들면 다음과 같습니다.
#![allow(unused)]
fn main() {
trait Trait<'a> {}
fn foo<'a, T: Trait<'a>>(_: &'a String, _: T) {}
}
이 예제에서 라이프타임 매개변수 'a는 where 절 T: Trait<'a>에 나타나므로 조기 바인딩으로 간주됩니다. 이는 'a: 'a와 같은 “자명한” where 절이나 함수 인자의 적격성에 의해 암시되는 where 절에도 마찬가지로 적용됩니다. 예를 들면 다음과 같습니다:
#![allow(unused)]
fn main() {
fn foo<'a: 'a>(_: &'a String) {}
fn bar<'a, T: 'a>(_: &'a T) {}
}
이 두 함수 모두에서 라이프타임 매개변수 'a는 조기 바인딩으로 간주됩니다. 비록 그것이 사용된 where 절이 실제로는 호출자에게 아무런 제약도 부과하지 않는다고 볼 수 있더라도 그렇습니다.
이러한 제한이 존재하는 이유는 다음 두 가지가 결합된 결과입니다:
- 후기 바인딩 매개변수에 대한 바운드는 인스턴스화되기 전까지 증명할 수 없습니다
- 함수 포인터와 트레이트 객체에는 기반 함수에서 비롯된, 아직 증명되지 않은 where 절을 표현할 방법이 없습니다
다음 예제를 살펴보십시오:
#![allow(unused)]
fn main() {
trait Trait<'a> {}
fn foo<'a, T: Trait<'a>>(_: &'a T) {}
let f = foo::<String>;
let f = f as for<'a> fn(&'a String);
f(&String::new());
}
String은 어떤 라이프타임에 대해서도 Trait을 구현하지 않으므로, 타입 검사 중 어느 시점에는 이 코드에 대해 오류가 발생해야 합니다.
라이프타임 'a가 후기 바인딩이라면 이를 검사하기 어려워집니다. foo를 명명하는 시점에는 T: Trait<'a> 트레이트 바운드의 일부로 어떤 라이프타임을 사용해야 하는지 알 수 없습니다. 아직 인스턴스화되지 않았기 때문입니다. 함수 항목 타입을 함수 포인터로 강제 변환할 때, 함수를 호출할 때 증명해야 하는 String: Trait<'a> 트레이트 바운드를 추적할 방법이 없습니다.
라이프타임 'a가 조기 바인딩이라면(현재 rustc 구현에서는 그렇습니다), 함수 foo의 이름을 지정할 때 트레이트 바운드를 검사할 수 있습니다. where 절에 사용되는 매개변수를 조기 바인딩으로 요구하면 함수에 정의된 where 절을 검사할 자연스러운 지점이 생깁니다.
마지막으로, 라이프타임이 암시적 바운드에 사용되는 경우에는 조기 바인딩일 것을 요구하지 않습니다. 예를 들면 다음과 같습니다:
#![allow(unused)]
fn main() {
fn foo<'a, T>(_: &'a T) {}
let f = foo;
f(&String::new());
f(&String::new());
}
'a가 T: 'a의 성립을 암묵적으로 요구하는 &'a T 타입에 사용됨에도 불구하고 이 코드는 컴파일되며, 이는 해당 라이프타임 매개변수가 후기 바인딩임을 보여 줍니다. 암시적 바운드는 특별하게 취급될 수 있습니다. 암시적 바운드를 도입하는 모든 타입은 함수 포인터 타입의 시그니처에 포함되므로, 함수를 호출할 때 T: 'a를 증명해야 한다는 것을 알 수 있기 때문입니다.
인자 타입에 의해 제약되어야 합니다
함수 항목 타입에 대한 내장 impl이 제약되지 않은 제네릭 매개변수를 갖게 되지 않도록 하는 것이 중요합니다. 그렇지 않으면 비건전성으로 이어질 수 있기 때문입니다. 이는 사용자가 작성한 impl에 적용되는 것과 같은 종류의 제한으로, 예를 들어 다음 코드는 오류를 일으킵니다:
#![allow(unused)]
fn main() {
trait Trait {
type Assoc;
}
impl<'a> Trait for u8 {
type Assoc = &'a String;
}
}
함수 항목에 대한 내장 impl의 유사한 예는 다음과 같습니다:
fn foo<'a>() -> &'a String { /* ... */ }
라이프타임 매개변수 'a가 후기 바인딩이라면 제약되지 않은 라이프타임을 가진 내장 impl이 생기게 됩니다. 이를 보여 주기 위해 'a가 후기 바인딩인 경우의 함수 항목 타입과 그 impl에 대한 디슈가링을 직접 작성해 볼 수 있습니다:
// NOTE: this is just for demonstration, in practice `'a` is early bound
struct FooFnItem;
impl<'a> Fn<()> for FooFnItem {
type Output = &'a String;
/* fn call(...) -> ... { ... } */
}
이러한 상황을 피하기 위해 'a를 조기 바인딩으로 간주합니다. 그러면 impl의 라이프타임이 self 타입에 의해 제약됩니다:
struct FooFnItem<'a>(PhantomData<fn() -> &'a String>);
impl<'a> Fn<()> for FooFnItem<'a> {
type Output = &'a String;
/* fn call(...) -> ... { ... } */
}
ty 모듈: 타입 표현하기
ty 모듈은 Rust 컴파일러가 내부적으로 타입을 표현하는 방식을 정의합니다. 또한 컴파일러의 핵심 자료 구조인 타이핑 컨텍스트(tcx 또는 TyCtxt)도 정의합니다.
ty::Ty
rustc가 타입을 어떻게 표현하는지 이야기할 때는 보통 Ty라는 타입을 가리킵니다. 컴파일러에는 Ty와 관련된 모듈과 타입이 꽤 많습니다(Ty 문서).
여기서 말하는 Ty는 구체적으로 rustc_middle::ty::Ty입니다(rustc_hir::Ty가 아닙니다). 이 구분이 중요하므로, ty::Ty의 세부 사항을 살펴보기 전에 먼저 이를 설명하겠습니다.
rustc_hir::Ty 대 ty::Ty
rustc의 HIR은 고수준 중간 표현으로 생각할 수 있습니다. HIR은 사용자가 작성한 구문을 표현하며 파싱과 약간의 디슈가링을 거친 뒤에 얻어지므로, 대체로 AST와 비슷합니다(이 장을 참고하십시오). HIR에도 타입 표현이 있지만, 실제로는 사용자가 작성한 것, 즉 그 타입을 나타내기 위해 사용자가 쓴 것을 더 많이 반영합니다.
반면 ty::Ty는 타입의 의미론, 즉 사용자가 작성한 것의 의미를 표현합니다. 예를 들어 rustc_hir::Ty는 사용자가 프로그램에서 u32라는 이름을 두 번 사용했다는 사실을 기록하지만, ty::Ty는 두 사용이 모두 같은 타입을 가리킨다는 사실을 기록합니다.
예: fn foo(x: u32) → u32 { x }
이 함수에서 u32가 두 번 나타나는 것을 볼 수 있습니다. 우리는 이것이 같은 타입이라는 것, 즉 함수가 인자를 받아 같은 타입의 인자를 반환한다는 것을 알지만, HIR의 관점에서는 이들이 프로그램의 서로 다른 두 위치에 나타나므로 서로 다른 두 개의 타입 인스턴스가 존재하게 됩니다. 즉, 이들은 서로 다른 두 개의 Span(위치)을 가집니다.
예: fn foo(x: &u32) -> &u32
또한 HIR에는 일부 정보가 빠져 있을 수 있습니다. 이 &u32 타입은 불완전합니다. 완전한 Rust 타입에는 실제로 라이프타임이 있지만, 우리는 그 라이프타임을 작성할 필요가 없었기 때문입니다. 정보를 채워 넣는 생략 규칙도 몇 가지 있습니다. 그 결과는 fn foo<'a>(x: &'a u32) -> &'a u32와 같은 모습일 수 있습니다.
HIR 수준에서는 이러한 것들이 명시되지 않으므로, 그림이 다소 불완전하다고 말할 수 있습니다. 그러나 ty::Ty 수준에서는 이러한 세부 사항이 추가되어 완전해집니다. 게다가 u32와 같은 주어진 타입에 대해 정확히 하나의 ty::Ty만 존재하며, 그 ty::Ty는 rustc_hir::Ty와 달리 특정 사용처가 아니라 프로그램 전체의 모든 u32에 사용됩니다.
요약하면 다음과 같습니다.
rustc_hir::Ty | ty::Ty |
|---|---|
| 타입의 구문을 기술합니다. 즉, 사용자가 작성한 것(약간의 디슈가링 포함)입니다. | 타입의 의미론을 기술합니다. 즉, 사용자가 작성한 것의 의미입니다. |
각 rustc_hir::Ty는 프로그램의 해당 위치에 대응하는 고유한 스팬을 가집니다. | 사용자 프로그램의 어느 한 위치에 대응하지 않습니다. |
rustc_hir::Ty에는 제네릭과 라이프타임이 있지만, 그중 일부 라이프타임은 LifetimeKind::Implicit 같은 특수 마커입니다. | ty::Ty는 사용자가 제네릭과 라이프타임을 생략했더라도 이를 포함한 전체 타입을 가집니다 |
fn foo(x: u32) -> u32 { } - u32의 각 사용을 나타내는 rustc_hir::Ty가 두 개 있고, 각각 자신의 Span을 가지며, rustc_hir::Ty만으로는 둘이 같은 타입이라는 것을 알 수 없습니다 | fn foo(x: u32) -> u32 { } - 프로그램 전체에서 u32의 모든 인스턴스에 대해 하나의 ty::Ty가 있으며, ty::Ty를 통해 u32의 두 사용이 같은 타입을 의미한다는 것을 알 수 있습니다. |
fn foo(x: &u32) -> &u32 { } - 이번에도 rustc_hir::Ty가 두 개입니다. 참조의 라이프타임은 LifetimeKind::Implicit라는 특수 마커를 사용하여 rustc_hir::Ty에 나타납니다. | fn foo(x: &u32) -> &u32 { }- 하나의 ty::Ty입니다. 이 ty::Ty는 숨겨진 라이프타임 매개변수를 가집니다. |
순서
HIR은 AST로부터 직접 만들어지므로, 어떤 ty::Ty보다도 먼저 생성됩니다. HIR이 만들어진 후에는 기본적인 타입 추론과 타입 검사가 수행됩니다. 타입 추론 중에 우리는 모든 것의 ty::Ty가 무엇인지 알아내고, 어떤 것의 타입이 모호한지도 검사합니다. 그런 다음 ty::Ty는 모든 것이 예상된 타입을 가지는지 확인하는 타입 검사에 사용됩니다. hir_ty_lowering 모듈은 rustc_hir::Ty를 ty::Ty로 로워링하는 코드가 있는 곳입니다. 주로 사용되는 루틴은 lower_ty입니다. 이 작업은 타입 검사 단계에서 일어나지만, “이 함수는 어떤 인자 타입을 기대하는가?“와 같은 질문을 하려는 컴파일러의 다른 부분에서도 일어납니다.
의미론이 Ty의 두 인스턴스를 어떻게 이끄는가
HIR은 타입 정보에 대해 가장 적게 가정하는 관점이라고 생각할 수 있습니다. 우리는 두 대상이 같은 것으로 증명되기 전까지는 서로 다르다고 가정합니다. 다시 말해, 그것들에 대해 아는 것이 적으므로 그것들에 대해 가정하는 것도 적어야 합니다.
구문적으로 그것들은 두 개의 문자열, 즉 N번째 줄 20번째 열의 "u32"와 N번째 줄 35번째 열의 "u32"입니다. 우리는 아직 그것들이 같다는 것을 알지 못합니다. 그래서 HIR에서는 그것들을 서로 다른 것처럼 취급합니다. 나중에 우리는 그것들이 의미적으로 같은 타입이라는 것을 결정하며, 그것이 우리가 사용하는 ty::Ty입니다.
또 다른 예로 fn foo<T>(x: T) -> u32를 생각해 봅시다. 누군가 foo::<u32>(0)를 호출한다고 가정해 봅시다. 이는 (이 호출에서) T와 u32가 실제로는 같은 타입으로 밝혀진다는 뜻이므로, 결국에는 같은 ty::Ty에 이르게 되지만, rustc_hir::Ty는 서로 다릅니다. (다만 이는 약간 지나치게 단순화한 설명입니다. 타입 검사 중에는 함수를 제네릭하게 검사하므로 여전히 u32와 구별되는 T를 가지기 때문입니다. 나중에 코드 생성을 할 때는 항상 각 함수의 “단형화된”(완전히 치환된) 버전을 다루게 되므로, T가 무엇을 나타내는지(구체적으로는 그것이 u32라는 것을) 알게 됩니다.)
예를 하나 더 들어 보겠습니다.
#![allow(unused)]
fn main() {
mod a {
type X = u32;
pub fn foo(x: X) -> u32 { 22 }
}
mod b {
type X = i32;
pub fn foo(x: X) -> i32 { x }
}
}
여기서 타입 X는 당연히 맥락에 따라 달라집니다. rustc_hir::Ty를 살펴보면 두 경우 모두 X가 별칭이라는 결과를 얻습니다(다만 이름 해석을 거쳐 서로 다른 별칭으로 매핑됩니다). 그러나 ty::Ty 시그니처를 살펴보면 fn(u32) -> u32 또는 fn(i32) -> i32 중 하나가 됩니다(타입 별칭은 완전히 전개됩니다).
ty::Ty 구현
rustc_middle::ty::Ty는 실제로는 Interned<WithCachedTypeInfo<TyKind>>를 감싸는 래퍼입니다. 일반적으로 Interned는 무시해도 됩니다. 이것에 명시적으로 접근할 일은 사실상 없습니다. 우리는 항상 이들을 Ty 안에 숨기고 Deref impl이나 메서드를 통해 건너뜁니다. TyKind는 다양한 Rust 타입(예: 기본 타입, 참조, 대수적 데이터 타입, 제네릭, 라이프타임 등)을 나타내는 배리언트를 가진 큰 열거형입니다. WithCachedTypeInfo에는 flags와 outer_exclusive_binder 같은 몇 가지 캐시된 값이 있습니다. 이 값들은 효율을 위한 편리한 방편으로, 우리가 알고 싶어 할 만한 타입 정보를 요약해 두지만, 여기서는 크게 중요하지 않습니다. 마지막으로 Interned 덕분에 ty::Ty는 얇은 포인터와 같은 타입이 될 수 있습니다. 이를 통해 인터닝의 다른 이점과 더불어 동등성을 저렴하게 비교할 수 있습니다.
타입 할당 및 다루기
새 타입을 할당하려면 Ty에 정의된 다양한 new_* 메서드를 사용할 수 있습니다. 이 메서드들의 이름은 대부분 여러 종류의 타입에 대응합니다. 예를 들면 다음과 같습니다.
let array_ty = Ty::new_array_with_const_len(tcx, ty, count);
이 메서드들은 모두 Ty<'tcx>를 반환합니다. 돌려받는 라이프타임은 이 tcx가 접근할 수 있는 아레나의 라이프타임이라는 점에 유의하십시오. 타입은 항상 정준화되고 인터닝됩니다(따라서 완전히 같은 타입을 두 번 할당하는 일은 없습니다).
tcx 자체의 필드에 접근하여 여러 공통 타입을 찾을 수도 있습니다. tcx.types.bool, tcx.types.char 등이 그 예입니다. (자세한 내용은 CommonTypes를 참고하십시오.)
타입 비교
타입은 인터닝되므로 ==를 사용해 동등성을 효율적으로 비교할 수 있습니다. 그러나 해싱을 하면서 중복을 찾는 경우가 아니라면, 이는 거의 항상 원하는 동작이 아닙니다. Rust에서는 같은 타입을 표현하는 방법이 여러 가지인 경우가 많고, 특히 추론이 관여하면 더욱 그렇기 때문입니다.
예를 들어, 타입 {integer}(정수 추론 변수인 ty::Infer(ty::IntVar(..))로, 0 같은 정수 리터럴의 타입입니다)와 u8(ty::UInt(..))은 서로 대입할 수 있는지 검사할 때(진단 코드에서 흔한 작업입니다) 같은 것으로 취급해야 하는 경우가 많습니다. 하지만 둘은 서로 다른 타입이므로 이들에 대한 ==는 false를 반환합니다.
두 타입을 올바르게 비교하는 가장 간단한 방법에는 추론 컨텍스트(infcx)가 필요합니다. 추론 컨텍스트가 있다면 infcx.can_eq(param_env, ty1, ty2)를 사용하여 두 타입을 같게 만들 수 있는지 확인할 수 있습니다. 진단 과정에서 확인하려는 것은 대개 이것입니다. 진단은 두 타입이 컴파일러의 타입 검사 계층에서 동일하게 표현되는지가 아니라, 서로 대입될 수 있는지와 같은 질문에 관심을 두기 때문입니다.
추론 컨텍스트를 다룰 때는 타입 안에 있을 수 있는 추론 변수가 실제로 그 추론 컨텍스트에 속하는지 주의 깊게 확인해야 합니다. 이미 추론 컨텍스트에 접근할 수 있는 함수 안에 있다면 이 조건은 충족되어 있을 것입니다. 구체적으로는 HIR 타입 검사나 MIR 빌림 검사 중에 이러한 경우에 해당합니다.
또 다른 고려 사항은 정규화입니다. 두 타입이 실제로는 같지만, 한쪽이 연관 타입 뒤에 가려져 있을 수 있습니다. 이들을 올바르게 비교하려면 먼저 타입을 정규화해야 합니다. 이는 주로 HIR 타입 검사 중에, 그리고 TyCtxt 쿼리에서 얻은 모든 타입(예: tcx.type_of()에서 얻은 타입)에서 문제가 됩니다.
타입 검사 중에 FnCtxt나 ObligationCtxt를 사용할 수 있다면, 타입을 정규화할 때 이들에 대해 .normalize(ty)를 사용해야 합니다. 타입 검사가 끝난 뒤에는 진단 코드에서 tcx.normalize_erasing_regions(ty)를 사용할 수 있습니다.
Ty에 ==를 사용해도 괜찮은 경우도 있습니다. 예를 들어 후기 린트(late lint)나 단형화(monomorphization) 이후가 그렇습니다. 이 시점에는 타입 검사가 완료되어 모든 추론 변수가 해결되었고 모든 리전이 지워졌기 때문입니다. 이러한 경우 추론 변수나 정규화가 문제가 되지 않는다는 것을 알고 있다면, 해당 린트에 #[allow]나 #[expect]를 적용하는 것을 권장합니다.
진단 코드에서 추론 컨텍스트에 접근할 수 없는 경우, (타입 검사 중처럼) 어딘가에서 추론 컨텍스트를 사용할 수 있다면 함수 호출을 거쳐 전달해야 합니다.
추론 컨텍스트를 전혀 사용할 수 없다면 type-inference에서 설명한 대로 새로 만들 수 있습니다. 하지만 이는 관련된 타입(예를 들어 tcx.type_of() 같은 쿼리에서 얻은 타입)을 fresh_args_for_item으로 새 추론 변수로 실제로 치환한 경우에만 유용합니다. 이를 이용해 “임의의 T에 대해 Vec<T>를 Vec<u32>와 단일화할 수 있는가?” 같은 질문에 답할 수 있습니다.
ty::TyKind 배리언트
참고: TyKind는 함수형 프로그래밍의 Kind 개념이 아닙니다.
컴파일러에서 Ty를 다룰 때는 흔히 타입의 종류에 대해 패턴 매칭을 합니다.
fn foo(x: Ty<'tcx>) {
match x.kind {
...
}
}
kind 필드의 타입은 TyKind<'tcx>이며, 이는 컴파일러의 모든 타입 종류를 정의하는 열거형입니다.
참고: 타입 추론 중에 타입의
kind필드를 검사하는 것은 위험할 수 있습니다. 고려해야 할 추론 변수나 다른 요소가 있을 수 있고, 때로는 타입이 아직 알려지지 않았다가 나중에 알려지기도 하기 때문입니다.
관련된 타입이 많으며, 이에 대해서는 차차 다루겠습니다(예: 리전/라이프타임, “치환” 등).
TyKind 열거형에는 많은 배리언트가 있으며, 문서에서 확인할 수 있습니다. 다음은 그 일부입니다.
- 대수적 데이터 타입(ADT) 대수적 데이터 타입은
struct,enum또는union입니다. 내부적으로struct,enum,union은 사실 같은 방식으로 구현되며, 모두ty::TyKind::Adt입니다. 기본적으로 사용자 정의 타입입니다. 이에 대해서는 나중에 더 자세히 설명하겠습니다. - Foreign
extern type T에 해당합니다. - Str str 타입입니다. 사용자가
&str를 작성하면, 그 타입의str부분을Str로 표현합니다. - 슬라이스는
[T]에 해당합니다. - 배열은
[T; n]에 해당합니다. - RawPtr
*mut T또는*const T에 해당합니다. - Ref
Ref는 안전한 참조인&'a mut T또는&'a T를 나타냅니다.Ref에는 몇 가지 연관된 부분이 있는데, 예를 들어 참조가 가리키는 타입인Ty<'tcx>가 있습니다.Region<'tcx>는 참조의 라이프타임 또는 리전이며,Mutability는 참조가 가변인지 여부를 나타냅니다. - Param 타입 매개변수를 나타냅니다(예:
Vec<T>의T). - Error 더 나은 진단을 출력할 수 있도록 어딘가에 있는 타입 오류를 나타냅니다. 이에 대해서는 나중에 더 자세히 논의하겠습니다.
- 그 밖에도 많습니다…
임포트 규칙
엄격하게 정해진 규칙은 없지만, ty 모듈은 보통 다음과 같이 사용합니다:
use ty::{self, Ty, TyCtxt};
특히 Ty 타입과 TyCtxt 타입은 매우 자주 쓰이므로 직접 임포트합니다. 그 밖의 타입은 흔히 명시적인 ty:: 접두사를 붙여 참조합니다(예: ty::TraitRef<'tcx>). 다만 일부 모듈은 더 많거나 더 적은 이름을 명시적으로 임포트하기도 합니다.
타입 오류
사용자가 타입 오류를 일으켰을 때 생성되는 TyKind::Error가 있습니다. 이 타입을 전파하고 이로 인해 발생하는 다른 오류를 억제하여, 연쇄적인 컴파일러 오류 메시지로 사용자를 압도하지 않으려는 것이 그 의도입니다.
TyKind::Error에는 중요한 불변 조건이 있습니다. 컴파일러는 오류가 이미 사용자에게 보고되었음을 알고 있는 경우가 아니라면 Error를 절대 생성해서는 안 됩니다. 이는 보통 (a) 바로 그 자리에서 방금 오류를 보고했거나 (b) 기존의 Error 타입을 전파하고 있기 때문입니다(이 경우 해당 오류 타입이 생성될 때 오류가 이미 보고되었어야 합니다).
이 불변 조건을 유지하는 것이 중요한 이유는 Error 타입의 핵심 목적이 다른 오류를 억제하는 것, 즉 그 오류들을 보고하지 않는 것이기 때문입니다. 실제로 사용자에게 오류를 내보내지 않은 채 Error 타입을 생성하면 이후의 오류들이 억제될 수 있고, 컴파일이 의도치 않게 성공할 수도 있습니다!
때로는 세 번째 경우도 있습니다. 오류가 보고되었다고 생각하지만, 그 오류가 이 자리가 아니라 컴파일의 더 이른 단계에서 보고되었으리라 생각하는 경우입니다. 이 경우 delayed_bug 또는 span_delayed_bug로 “지연된 버그(delayed bug)“를 생성할 수 있습니다. 이렇게 하면 컴파일이 오류를 낼 것으로 예상한다는 기록이 남으며, 만약 컴파일이 성공하면 컴파일러 버그 보고가 발생합니다.
안전성을 높이기 위해, 실제로 rustc_middle::ty 외부에서는 TyKind::Error 값을 생성할 수 없습니다. TyKind::Error에는 다른 곳에서 생성되지 못하도록 막는 비공개 멤버가 있습니다. 대신 Ty::new_error 또는 Ty::new_error_with_message 메서드를 사용해야 합니다. 이 메서드들은 ErrorGuaranteed를 인자로 받거나, 종류가 Error인 인턴된 Ty를 반환하기 전에 span_delayed_bug를 호출합니다. 이미 span_delayed_bug를 사용하려던 참이었다면, 중복된 지연 버그를 피하기 위해 대신 span과 메시지를 ty_error_with_message에 넘기기만 하면 됩니다.
TyKind 배리언트 축약 구문
Ty의 디버그 출력을 살펴보거나 단순히 컴파일러의 여러 타입에 관해 이야기할 때, 유효한 Rust는 아니지만 타입에 관한 내부 정보를 간결하게 나타내기 위해 사용되는 구문을 접할 수 있습니다. 다음은 여러 구문이 실제로 무엇을 의미하는지 알려 주는 간단한 참조용 치트 시트입니다.
- 제네릭 매개변수:
{name}/#{index}예:T/#0. 여기서index는 제네릭 매개변수 목록에서의 위치에 해당합니다 - 추론 변수:
?{id}예:?x/?0. 여기서id는 추론 변수를 식별합니다 - 바인더의 변수:
^{binder}_{index}예:^0_x/^0_2. 여기서binder와index는 어느 바인더의 어느 변수를 가리키는지 식별합니다 - 플레이스홀더:
!{id}또는!{id}_{universe}예:!x/!0/!x_2/!0_2. 지정된 유니버스 안의 어떤 고유한 타입을 나타냅니다. 유니버스가0일 때는 흔히 생략됩니다
이 내용은 이후 장에서 더 자세히 다룹니다.
ADT와 제네릭 인자
ADT라는 용어는 “대수적 데이터 타입(Algebraic data type)“을 뜻하며, Rust에서는 구조체, 열거형 또는 공용체를 가리킵니다.
ADT의 표현
MyStruct<u32>와 같은 타입을 예로 들어 봅시다. 여기서 MyStruct는 다음과 같이 정의되어 있습니다.
struct MyStruct<T> { x: u8, y: T }
MyStruct<u32> 타입은 TyKind::Adt의 인스턴스가 됩니다.
Adt(&'tcx AdtDef, GenericArgs<'tcx>)
// ------------ ---------------
// (1) (2)
//
// (1) represents the `MyStruct` part
// (2) represents the `<u32>`, or "substitutions" / generic arguments
이 인스턴스는 두 부분으로 구성됩니다.
AdtDef는 구조체/열거형/공용체를 참조하지만, 그 타입 매개변수에 대한 값은 포함하지 않습니다. 이 예시에서는 인자u32가 없는MyStruct부분이 이에 해당합니다. (HIR에서는 구조체, 열거형, 공용체가 서로 다르게 표현되지만,ty::Ty에서는 모두TyKind::Adt로 표현된다는 점에 유의하십시오.)GenericArgs는 제네릭 매개변수를 대체할 값들의 목록입니다.MyStruct<u32>예시에서는[u32]와 같은 목록이 만들어집니다. 제네릭과 치환에 대해서는 잠시 후에 더 자세히 살펴보겠습니다.
AdtDef와 DefId
소스 코드에 정의된 모든 타입에는 고유한 DefId가 있습니다(이 장을 참고하십시오). 여기에는 ADT와 제네릭도 포함됩니다. 위에서 제시한 MyStruct<T> 정의에는 DefId가 두 개 있습니다. 하나는 MyStruct에 대한 것이고, 다른 하나는 T에 대한 것입니다. 위 코드는 u32에 대해 새로운 DefId를 생성하지 않는다는 점에 주목하십시오. u32는 그 코드에서 정의되지 않고 참조되기만 하기 때문입니다.
AdtDef는 대략 유용한 헬퍼 메서드를 많이 갖춘 DefId의 래퍼라고 할 수 있습니다. AdtDef와 DefId는 본질적으로 일대일 관계입니다. tcx.adt_def(def_id) 쿼리를 사용하면 DefId에 대한 AdtDef를 얻을 수 있습니다. 'tcx 라이프타임에서 알 수 있듯이 AdtDef는 모두 인터닝되어 있습니다.
질문: 왜 AdtDef “내부“에서 대체하지 않습니까?
제네릭 구조체를 (AdtDef, args)로 표현한다는 점을 떠올려 보십시오. 그렇다면 왜 굳이 이러한 방식을 사용합니까?
타입을 표현하는 다른 방법으로, 모든 타입이 이미 대체된 완전히 대체된 형태의 AdtDef를 항상 새로 만드는 방식을 택할 수도 있었습니다. 이 방식이 덜 번거로워 보입니다. 그러나 (AdtDef, args) 방식에는 이에 비해 몇 가지 장점이 있습니다.
첫째, (AdtDef, args) 방식은 효율성 면에서 이점이 있습니다.
struct MyStruct<T> {
... 100s of fields ...
}
// Want to do: MyStruct<A> ==> MyStruct<B>
이와 같은 예시에서는 A에 대한 단 하나의 참조를 B로 바꾸기만 하면 MyStruct<A>를 MyStruct<B> 등으로 매우 저렴하게 인스턴스화할 수 있습니다. 하지만 모든 필드를 즉시 인스턴스화한다면 AdtDef의 모든 필드를 순회하며 각 필드의 타입을 모두 갱신해야 할 수도 있으므로 훨씬 더 많은 작업이 필요할 수 있습니다.
좀 더 깊이 들어가면, 이는 Rust의 구조체가 명목적 타입이라는 사실에 대응합니다. 즉, 구조체는 그 이름으로 정의되며, 그 내용은 타입 자체 “안에” 담겨 다니는 것이 아니라 해당 이름의 정의로부터 색인된다는 뜻입니다.
GenericArgs 타입
제네릭 타입 MyType<A, B, …>가 주어지면 MyType에 대한 제네릭 인자 목록을 저장해야 합니다.
rustc에서는 GenericArgs를 사용하여 이를 수행합니다. GenericArgs는 제네릭 항목의 제네릭 인자 목록을 나타내는 GenericArg 슬라이스를 가리키는 얇은 포인터입니다. 예를 들어, 두 개의 타입 매개변수 K와 V를 가진 struct HashMap<K, V>가 주어졌을 때, HashMap<i32, u32> 타입을 나타내는 데 사용되는 GenericArgs는 &'tcx [tcx.types.i32, tcx.types.u32]로 표현됩니다.
GenericArg는 개념적으로 타입 인자용, 상수 인자용, 라이프타임 인자용의 세 가지 변형을 가진 enum입니다. 실제로는 GenericArgKind로 표현되며, GenericArg는 이를 GenericArgKind로 변환하는 메서드를 갖춘, 공간 효율이 더 높은 버전입니다.
실제 GenericArg 구조체는 타입, 라이프타임 또는 상수를 인터닝된 포인터로 저장하며, 판별값은 하위 2비트에 저장됩니다. GenericArgs 구현 자체를 다루는 경우가 아니라면 일반적으로 GenericArg를 직접 다룰 필요가 없으며, 대신 GenericArg::unpack() 메서드로 얻을 수 있는 안전한 GenericArgKind 추상화를 사용하십시오.
경우에 따라 GenericArg를 직접 생성해야 할 수도 있는데, 이는 Ty/Const/Region::into() 또는 GenericArgKind::pack을 통해 할 수 있습니다.
// An example of unpacking and packing a generic argument.
fn deal_with_generic_arg<'tcx>(generic_arg: GenericArg<'tcx>) -> GenericArg<'tcx> {
// Unpack a raw `GenericArg` to deal with it safely.
let new_generic_arg: GenericArgKind<'tcx> = match generic_arg.unpack() {
GenericArgKind::Type(ty) => { /* ... */ }
GenericArgKind::Lifetime(lt) => { /* ... */ }
GenericArgKind::Const(ct) => { /* ... */ }
};
// Pack the `GenericArgKind` to store it in a generic args list.
new_generic_arg.pack()
}
이 모든 것을 종합하면 다음과 같습니다:
struct MyStruct<T>(T);
type Foo = MyStruct<u32>
Foo 타입 별칭에 작성된 MyStruct<U>는 다음과 같이 표현됩니다:
MyStruct에 대한AdtDef(및 그에 대응하는DefId)가 있습니다.[GenericArgKind::Type(Ty(u32))]목록을 담은GenericArgs가 있습니다.- 마지막으로 위에 나열한
AdtDef와GenericArgs를 가진TyKind::Adt가 있습니다.
중첩된 제네릭 인자
struct MyStruct<T>(T);
impl<T> MyStruct<T> {
fn func<T2, T3>() {}
}
fn main() {
MyStruct::<u32>::func::<bool, char>();
}
MyStruct::<u32>::func::<bool, char> 구문은 튜플로 표현됩니다. 즉, func를 가리키는 DefId와, 이를 둘러싼 모든 제네릭 매개변수를 “순회“하는 GenericArgs 목록으로 이루어지며, 이 경우 목록은 [u32, bool, char]가 됩니다.
ty::Generics 타입(generics_of 쿼리가 반환함)은 중첩된 계층 구조가 어떻게 하나의 목록으로 평탄화되는지에 대한 정보를 담고 있으며, GenericArgs 목록의 어느 인덱스가 어느 제네릭에 대응하는지 알아낼 수 있게 해 줍니다. 동작 방식의 전반적인 원칙은 가장 바깥쪽에서 가장 안쪽으로(예제에서 T가 T2보다 먼저), 왼쪽에서 오른쪽으로(T2가 T3보다 먼저)이지만, 몇 가지 복잡한 점이 있습니다:
- 트레이트에는 암묵적인
Self제네릭 매개변수가 있으며, 이는 첫 번째(즉, 0번째) 제네릭 매개변수입니다.Self가 모든 상황에서 제네릭 매개변수를 의미하는 것은 아니라는 점에 유의하십시오. Res::SelfTyAlias와 Res::SelfCtor를 참고하십시오. - 조기 바인딩된 제네릭 매개변수만 포함되며, 후기 바인딩된 제네릭은 포함되지 않습니다.
- … 그 밖에도 더 있습니다…
평탄화가 정확히 어떻게 동작하는지에 대한 자세한 내용은 ty::Generics를 확인하십시오.
Ty/Const/Region 매개변수
제네릭 아이템 내부에서는 스코프 안의 제네릭 매개변수를 사용하는 타입을 작성할 수 있습니다. 예를 들면 fn foo<'a, T>(_: &'a Vec<T>)와 같습니다. 이 경우 &'a Vec<T> 타입은 내부적으로 다음과 같이 표현됩니다.
TyKind::Ref(
RegionKind::LateParam(DefId(foo), DefId(foo::'a), "'a"),
TyKind::Adt(Vec, &[TyKind::Param("T", 0)])
)
제네릭 매개변수의 사용을 표현하는 방법은 세 가지가 있습니다.
- 조기 바인딩(early bound) 제네릭 매개변수에는
TyKind::Param/ConstKind::Param/RegionKind::EarlyParam을 사용합니다(참고: 모든 타입 매개변수와 상수 매개변수는 조기 바인딩으로 간주됩니다. 자세한 내용은 조기 바인딩 매개변수와 후기 바인딩 매개변수에 관한 장을 참고하십시오). - 고차 바운드(higher ranked bound)나 고차 타입(higher ranked type), 즉
for<'a> fn(&'a u32)또는for<'a> T: Trait<'a>를 통해 도입된 매개변수를 참조할 때는TyKind::Bound/ConstKind::Bound/RegionKind::Bound를 사용합니다. 이에 대해서는Binder에 관한 장에서 다룹니다. RegionKind::LateParam은 후기 바인딩 라이프타임 매개변수에 사용되며,LateParam은Binder인스턴스화에 관한 장에서 다룹니다.
이 장에서는 TyKind::Param, ConstKind::Param, RegionKind::EarlyParam만 다룹니다.
타입/상수 매개변수
TyKind::Param과 ConstKind::Param은 동일하게 구현되어 있으므로, 이 절에서는 편의상 TyKind::Param만 언급합니다. 다만 여기서 설명하는 모든 내용이 ConstKind::Param에도 똑같이 적용된다는 점을 기억하십시오.
각 TyKind::Param에는 매개변수의 이름과 인덱스라는 두 가지 정보가 들어 있습니다.
다음은 TyKind::Param을 사용하는 구체적인 예시입니다.
struct Foo<T>(Vec<T>);
Vec<T> 타입은 TyKind::Adt(Vec, &[GenericArgKind::Type(Param("T", 0))])로 표현됩니다.
이름은 말 그대로 타입 매개변수의 이름입니다. 타입 매개변수의 인덱스는 스코프 안의 제네릭 매개변수 목록에서 해당 매개변수의 순서를 나타내는 정수입니다. 여기에는 매개변수가 정의된 아이템보다 바깥 스코프에 있는 아이템에서 정의된 매개변수도 포함된다는 점에 유의하십시오. 다음 예시를 살펴보십시오.
struct Foo<A, B> {
// A would have index 0
// B would have index 1
.. // some fields
}
impl<X, Y> Foo<X, Y> {
fn method<Z>() {
// inside here, X, Y and Z are all in scope
// X has index 0
// Y has index 1
// Z has index 2
}
}
구체적으로 말하면, 매개변수가 정의된 아이템의 ty::Generics가 주어졌을 때 인덱스가 2라면, 최상위 parent부터 시작하여 세 번째로 도입되는 매개변수가 됩니다. 예를 들어 위 예시에서 Z는 인덱스가 2이며, impl 블록부터 시작하여 세 번째로 도입되는 제네릭 매개변수입니다.
인덱스는 Ty를 완전히 정의하며, 컴파일 중인 코드를 추론할 때 의미가 있는 TyKind::Param의 유일한 부분입니다.
일반적으로 이름이 무엇인지는 신경 쓰지 않으며 인덱스만 사용합니다. 이름은 진단 메시지와 디버그 로그를 위해 포함됩니다. 이름이 없다면 Vec<Param(0)>: Sized와 Vec<T>: Sized를 비교해 보면 알 수 있듯이 출력 내용을 이해하기가 매우 어려울 것입니다. 디버그 출력에서 매개변수 타입은 흔히 {name}/#{index} 형식으로 출력됩니다. 예를 들어 함수 foo에서 Vec<T>를 디버그 출력하면 Vec<T/#0>로 표시됩니다.
이름만 두는 방식으로 표현할 수도 있습니다. 그러나 인덱스를 사용하면 제네릭 매개변수를 어떤 인자로 인스턴스화할 때 GenericArgs에 인덱스로 접근할 수 있으므로 더 효율적입니다. 그렇지 않으면 GenericArgs를 HashMap<Symbol, GenericArg>로 저장하고, 제네릭 아이템을 사용할 때마다 해시맵을 조회해야 합니다.
이론적으로 인덱스를 사용하면 impl<A> Foo<A> { fn bar<A>() { .. } }처럼 같은 이름을 쓰는 서로 다른 매개변수를 여러 개 둘 수도 있습니다. 섀도잉을 금지하는 규칙 때문에 현재는 이렇게 하기 어렵지만, 이러한 언어 규칙은 향후 바뀔 수 있습니다.
라이프타임 매개변수
Ty/Const의 Param에 해당하는 단일 Param 변형과 달리, 라이프타임에는 리전 매개변수를 표현하기 위한 변형이 두 가지 있습니다. RegionKind::EarlyParam과 RegionKind::LateParam입니다. 그 이유는 함수가 조기 바인딩 매개변수와 후기 바인딩 매개변수를 구분하기 때문이며, 이는 앞선 장에서 다루었습니다(링크 참조).
RegionKind::EarlyParam은 Ty/Const의 Param 변형과 구조가 동일하며, 단순히 u32 인덱스와 Symbol로 이루어져 있습니다. 함수 항목이 아닌 항목에 정의된 라이프타임 매개변수에는 항상 ReEarlyParam을 사용합니다. 함수의 경우 조기 바인딩 매개변수에는 ReEarlyParam을, 후기 바인딩 매개변수에는 ReLateParam을 사용합니다. Ty 및 Const 매개변수와 마찬가지로, 이들도 흔히 'SYMBOL/#INDEX 형식으로 디버그 출력한다는 점에 유의하십시오.
예시는 다음과 같습니다.
// This function would have its signature represented as:
//
// ```
// fn(
// T/#2,
// Ref('a/#0, Ref(ReLateParam(...), u32))
// ) -> Ref(ReLateParam(...), u32)
// ```
fn foo<'a, 'b, T: 'a>(one: T, two: &'a &'b u32) -> &'b u32 {
...
}
RegionKind::LateParam은 바인더 인스턴스화에 관한 장에서 더 자세히 다룹니다.
TypeFoldable과 TypeFolder
이전 장에서 바인더를 인스턴스화하는 방법을 살펴보았습니다. 이를 위해서는 Early(Binder) 내부의 모든 것을 살펴보고 바운드 변수가 사용된 곳을 찾아 치환해야 합니다. 바인더는 Ty뿐만 아니라 임의의 Rust 타입 T를 감쌀 수 있습니다. 그렇다면 Early/Binder 타입의 instantiate 메서드는 어떻게 구현합니까?
답은 TypeFoldable과 TypeFolder라는 두 트레이트입니다.
TypeFoldable은 타입 정보를 담고 있는 타입이 구현합니다. 이 트레이트를 사용하면TypeFoldable의 내용을 재귀적으로 처리하고 그 내용에 원하는 작업을 수행할 수 있습니다.TypeFolder는TypeFoldable을 처리하는 동안 마주치는 타입에 대해 무엇을 할지 정의합니다.
예를 들어 TypeFolder 트레이트에는 타입을 입력으로 받아 새로운 타입을 결과로 반환하는 fold_ty 메서드가 있습니다. TypeFoldable은 자기 자신에 대해 TypeFolder의 fold_foo 메서드를 호출하여 TypeFolder가 자신의 내용(내부에 포함된 타입, 리전 등)에 접근할 수 있게 합니다.
이는 Rust에서 우리가 즐겨 사용하게 된 이터레이터 콤비네이터에 빗대어 생각할 수 있습니다:
vec.iter().map(|e1| foo(e2)).collect()
// ^^^^^^^^^^^^ analogous to `TypeFolder`
// ^^^ analogous to `TypeFoldable`
다시 정리하면 다음과 같습니다:
TypeFolder는 “map” 연산을 정의하는 트레이트입니다.TypeFoldable은 타입을 담고 있는 것들이 구현하는 트레이트입니다.
subst의 경우 TypeFolder인 ArgFolder로 구현되어 있음을 알 수 있습니다. 그 구현을 살펴보면 실제 치환이 어디에서 일어나는지 확인할 수 있습니다.
그런데 이 구현이 super_fold_with 메서드를 호출한다는 점도 눈치챘을 것입니다. 이것은 무엇입니까? 이것은 TypeFoldable의 메서드입니다. 다음과 같은 TypeFoldable 타입 MyFoldable을 생각해 봅시다:
struct MyFoldable<'tcx> {
def_id: DefId,
ty: Ty<'tcx>,
}
TypeFolder가 MyFoldable의 일부 필드만 새 값으로 바꾸려는 경우에는 MyFoldable에 대해 super_fold_with를 호출할 수 있습니다. 반대로 MyFoldable 전체를 다른 것으로 바꾸려는 경우에는 대신 fold_with(TypeFoldable의 다른 메서드)를 호출합니다.
거의 모든 경우에 우리는 구조체 전체를 바꾸려는 것이 아니라 구조체 안의 ty::Ty만 바꾸려 하므로, 보통은 super_fold_with를 호출합니다. MyFoldable의 전형적인 구현은 대략 다음과 같은 일을 합니다:
my_foldable: MyFoldable<'tcx>
my_foldable.subst(..., subst)
impl TypeFoldable for MyFoldable {
fn super_fold_with(&self, folder: &mut impl TypeFolder<'tcx>) -> MyFoldable {
MyFoldable {
def_id: self.def_id.fold_with(folder),
ty: self.ty.fold_with(folder),
}
}
fn super_visit_with(..) { }
}
여기서는 super_fold_with가 MyFoldable의 필드를 순회하며 각 필드에 대해 fold_with를 호출하도록 구현했다는 점에 주목하십시오. 즉, 폴더는 def_id와 ty는 바꿀 수 있지만 MyFoldable 구조체 전체를 바꿀 수는 없습니다.
모든 내용을 종합하는 예를 하나 더 들어 보겠습니다. Vec<Vec<X>>와 같은 타입이 있다고 가정합시다. 이때 ty::Ty는 Adt(Vec, &[Adt(Vec, &[Param(X)])])와 같은 형태가 됩니다. subst(X => u32)를 수행하려면 먼저 전체 타입을 살펴봅니다. 가장 바깥 수준에서는 치환할 것이 없으므로 한 수준 내려가서 Adt(Vec, &[Param(X)])를 살펴봅니다. 여기에도 여전히 치환할 것이 없으므로 다시 한 수준 내려갑니다. 이제 살펴보는 것은 Param(X)이며, 이는 치환할 수 있으므로 u32로 대체합니다. 더 이상 내려갈 수 없으므로 작업이 끝나며, 전체 결과는 Adt(Vec, &[Adt(Vec, &[u32])])입니다.
마지막으로 언급할 점이 있습니다. TypeFoldable을 폴딩할 때는 대개 대부분의 것을 변경하고 싶지 않습니다. 타입에 도달했을 때에만 무언가를 하고 싶습니다. 이는 구현이 사실상 필드의 TypeFoldable 구현으로 그대로 전달하기만 하는 TypeFoldable 타입이 많을 수 있다는 뜻입니다. 이러한 TypeFoldable 구현은 직접 작성하기가 상당히 번거로운 경향이 있습니다. 이러한 이유로 #![derive(TypeFoldable)]를 사용할 수 있게 해 주는 derive 매크로가 있습니다. 이 매크로는 여기에 정의되어 있습니다.
subst 치환의 경우 실제 폴더가 앞서 언급한 인덱싱을 수행합니다. 그곳에서 Folder를 정의하고 TypeFoldable에 대해 fold_with를 호출하여 자기 자신을 처리하게 합니다. 그런 다음 각 타입을 처리하는 메서드인 fold_ty는 ty::Param을 찾아 치환 목록의 항목으로 대체하고, 그렇지 않은 타입은 재귀적으로 처리합니다. 대체할 때는 ty_for_param을 호출하는데, 이 함수가 하는 일은 Param의 인덱스로 치환 목록을 인덱싱하는 것뿐입니다.
별칭과 정규화
별칭
Rust에는 어떤 “기저(underlying)” 타입과 같다고 간주되는 타입이 여럿 있습니다. 고유 연관 타입, 트레이트 연관 타입, 자유 타입 별칭(type Foo = u32), 불투명 타입(-> impl RPIT) 등이 그 예입니다. 이러한 타입을 “별칭“이라고 부릅니다. 별칭 타입은 TyKind::Alias 변형으로 표현되며, 별칭의 종류는 AliasTyKind 열거형으로 추적합니다.
정규화란 이러한 별칭 타입을 그것과 같은 기저 타입으로 대체하는 과정입니다. 예를 들어 타입 별칭 type Foo = u32가 있을 때 Foo를 정규화하면 u32가 됩니다.
타입 외에도 별칭이라는 개념은 상수/상수 제네릭에도 적용됩니다. 하지만 현재 컴파일러에서는 상수 별칭을 “일급 개념“으로 취급하지 않으므로, 이 장에서는 주로 타입을 기준으로 설명합니다(개념 자체는 상수에도 그대로 적용됩니다).
경직된 별칭, 모호한 별칭, 정규화되지 않은 별칭
별칭은 “경직(rigid)“되었거나, “모호(ambiguous)“하거나, 단순히 정규화되지 않은 상태일 수 있습니다.
타입의 “형태“가 바뀌지 않는다면 그 타입을 경직되었다고 간주합니다. 예를 들어 Box는 아무리 정규화해도 Box를 u32로 바꿀 수 없으므로 경직되어 있지만, <vec::IntoIter<u32> as Iterator>::Item은 u32로 정규화될 수 있으므로 경직되어 있지 않습니다.
별칭은 더 이상 결코 정규화할 수 없을 때 경직되었다고 합니다. 경직된 별칭의 구체적인 예로는 T: Iterator<Item = ...> 바운드 없이 T: Iterator 바운드만 있는 환경에서의 <T as Iterator>::Item을 들 수 있습니다:
#![allow(unused)]
fn main() {
fn foo<T: Iterator>() {
// This alias is *rigid*
let _: <T as Iterator>::Item;
}
fn bar<T: Iterator<Item = u32>>() {
// This alias is *not* rigid as it can be normalized to `u32`
let _: <T as Iterator>::Item;
}
}
별칭을 아직 정규화할 수는 없지만 현재 환경에서 결국 정규화할 수 있게 될 수도 있다면, 그 별칭을 “모호한” 별칭으로 간주합니다. 이는 별칭에 추론 변수가 포함되어 트레이트가 어떻게 구현되는지 결정할 수 없을 때 발생할 수 있습니다:
#![allow(unused)]
fn main() {
fn foo<T: Iterator, U: Iterator>() {
// This alias is considered to be "ambiguous"
let _: <_ as Iterator>::Item;
}
}
이러한 별칭을 “모호한” 별칭이라고 부르는 이유는 이것이 경직된 별칭인지 아닌지가 모호하기 때문입니다.
_: Iterator 트레이트 impl의 출처는 모호합니다(즉, 알 수 없습니다). 어떤 impl Iterator for u32일 수도 있고 어떤 T: Iterator 트레이트 바운드일 수도 있으며, 아직은 알 수 없습니다. _: Iterator가 성립하는 이유에 따라 이 별칭은 정규화되지 않은 별칭일 수도 있고 경직된 별칭일 수도 있으므로, 이것이 어떤 종류의 별칭인지 모호합니다.
마지막으로, 별칭은 단순히 정규화되지 않은 상태일 수도 있습니다. <Vec<u32> as IntoIterator>::Iter는 이미 std::vec::IntoIter<u32>로 정규화될 수 있지만 아직 정규화되지 않았을 뿐이므로 정규화되지 않은 별칭입니다.
자유 별칭과 고유 별칭은 경직되거나 모호할 수 없다는 점에 유의하십시오. 이들의 이름을 지정한다는 것은 곧 별칭의 기저 타입을 명시하는 별칭 정의를 이미 해석했다는 뜻이기 때문입니다.
발산하는 별칭
별칭의 정의가 정규화될 대상인, 별칭이 아닌 기저 타입을 명시하지 않으면 그 별칭은 “발산한다“고 간주합니다. 발산하는 별칭의 구체적인 예는 다음과 같습니다:
#![allow(unused)]
fn main() {
type Diverges = Diverges;
trait Trait {
type DivergingAssoc;
}
impl Trait for () {
type DivergingAssoc = <() as Trait>::DivergingAssoc;
}
}
이 예에서 Diverges와 DivergingAssoc은 모두 자기 자신과 같다고 정의된, 발산하는 타입 별칭의 “자명한” 사례입니다. Diverges가 정규화될 수 있는 기저 타입은 결코 존재하지 않습니다.
일반적으로 발산하는 별칭이 정의되면 오류를 내려고 하지만, 이는 전적으로 “최선의 노력(best effort)” 수준의 검사입니다. 앞의 예에서는 정의가 탐지할 수 있을 만큼 “충분히 단순“하므로 오류가 발생합니다. 그러나 더 복잡한 경우나 제네릭 매개변수의 일부 인스턴스화에서만 발산하는 별칭이 되는 경우에는 오류를 내지 않습니다:
#![allow(unused)]
fn main() {
trait Trait {
type DivergingAssoc<U: Trait>;
}
impl<T: ?Sized> Trait for T {
// This alias always diverges but we don't emit an error because
// the compiler can't "see" that.
type DivergingAssoc<U: Trait> = <U as Trait>::DivergingAssoc<U>;
}
}
결국 이는 타입 시스템의 별칭이 발산하지 않는다는 보장이 없음을 의미합니다. 별칭은 특정 제네릭 인자에 대해서만 발산할 수 있으므로, 별칭이 발산하는지 여부는 별칭이 완전히 구체화된 후에야 알 수 있다는 것도 의미합니다. 따라서 코드 생성/상수 평가 역시 발산하는 별칭을 처리해야 합니다.
trait Trait {
type Diverges<U: Trait>;
}
impl<T: ?Sized> Trait for T {
type Diverges<U: Trait> = <U as Trait>::Diverges<U>;
}
fn foo<T: Trait>() {
let a: T::Diverges<T>;
}
fn main() {
foo::<()>();
}
이 예시에서는 foo::<()>의 코드 생성 중에만 발산하는 별칭으로 인한 오류가 발생합니다. foo 호출을 제거하면 컴파일 오류가 발생하지 않습니다.
불투명 타입
불투명 타입은 비교적 특수한 종류의 별칭이며, 별도의 장에서 다룹니다.
상수 별칭
타입 별칭과 달리 상수 별칭은 타입 시스템에서 직접 표현되지 않습니다. 대신 상수 별칭은 항상 상수 항목을 가리키는 경로 표현식을 담은 익명 본문입니다. 즉, 타입 시스템에 존재하는 유일한 “상수 별칭“은 평가되지 않은 익명 상수 본문입니다.
따라서 ConstKind::Alias(AliasCtKind::Projection/Inherent/Free, _)는 존재하지 않습니다. 대신 익명 상수를 표현하는 데 사용되는 ConstKind::Unevaluated만 있습니다.
#![allow(unused)]
fn main() {
fn foo<const N: usize>() {}
const FREE_CONST: usize = 1 + 1;
fn bar() {
foo::<{ FREE_CONST }>();
// The const arg is represented with some anonymous constant:
// ```pseudo-rust
// const ANON: usize = FREE_CONST;
// foo::<ConstKind::Unevaluated(DefId(ANON), [])>();
// ```
}
}
이는 상수 제네릭 기능이 개선됨에 따라 바뀔 가능성이 높습니다. 예를 들어 feature(associated_const_equality)와 feature(min_generic_const_args)는 모두 상수 별칭을 (모든 상수 인자를 감싸는 익명 상수 없이) 타입과 비슷하게 처리해야 합니다.
정규화란 무엇인가
구조적 정규화와 깊은 정규화
정규화에는 구조적 정규화(얕은 정규화라고도 합니다)와 깊은 정규화의 두 가지 형태가 있습니다. 구조적 정규화는 타입의 “가장 바깥” 부분만 정규화하는 것으로 생각해야 합니다. 반면 깊은 정규화는 타입 안의 모든 별칭을 정규화합니다.
실제로는 구조적 정규화가 타입의 바깥 계층보다 더 많은 부분을 정규화할 수도 있지만, 이 동작에 의존해서는 안 됩니다. 바운드 변수(for<'a>)를 사용하는 정규화 불가능한 비경직 별칭은 어느 종류의 정규화로도 정규화할 수 없습니다.
예를 들어, 개념적으로 Vec<<u8 as Identity>::Assoc> 타입을 구조적으로 정규화하면 아무 변화가 없지만, 깊게 정규화하면 Vec<u8>이 됩니다. 실제로는 구조적 정규화로도 Vec<u8>이 되지만, 다시 말하지만 이에 의존해서는 안 됩니다.
별칭이 바운드 변수를 사용하도록 바꾸면 동작이 달라집니다. Vec<for<'a> fn(<&'a u8 as Identity>::Assoc)>는 구조적으로 정규화하면 아무 변화가 없지만, 깊게 정규화하면 Vec<for<'a> fn(&'a u8)>이 됩니다.
핵심 정규화 로직
별칭을 구조적으로 정규화하는 것은 별칭을 그 정의에서 같다고 정의된 대상으로 단순히 바꾸는 것보다 조금 더 미묘합니다. 별칭을 정규화한 결과는 경직(rigid) 타입이거나 (나중에 경직 타입으로 추론될) 추론 변수여야 합니다. 이를 위해 두 가지 일을 합니다.
첫째, 모호한 별칭을 정규화할 때는 그대로 두는 대신 추론 변수로 정규화합니다. 이는 두 가지 주요 효과가 있습니다.
- 추론 변수는 경직 타입이 아니지만 결국 항상 경직 타입으로 추론되므로, 정규화 결과를 다시 정규화할 필요가 없음을 보장합니다
- 타입이 경직되지 않은 모든 경우에 추론 변수를 사용하므로, 컴파일러의 나머지 부분은 모호한 별칭과 추론 변수를 모두 다룰 필요가 없습니다
둘째로, 정규화가 별칭 정의에 명시된 타입을 곧바로 반환하는 대신, 해당 타입을 먼저 정규화한 다음 반환합니다1. 이렇게 하는 이유는 정규화가 멱등성을 갖도록 하여 호출자가 정규화를 루프 안에서 반복해서 실행할 필요가 없게 하기 위함입니다.
#![allow(unused)]
#![feature(checked_type_alias)]
fn main() {
type Foo<T: Iterator> = Bar<T>;
type Bar<T: Iterator> = <T as Iterator>::Item;
fn foo() {
let a_: Foo<_>;
}
}
이 예시에서는 다음과 같습니다.
Foo<?x>를 정규화하면Bar<?x>가 되겠지만, 여기서는Foo와 같다고 정의된 타입 안의 별칭까지 정규화하려고 합니다Bar<?x>를 정규화하면<?x as Iterator>::Item이 되겠지만, 여기서도 마찬가지로Bar와 같다고 정의된 타입 안의 별칭까지 정규화하려고 합니다<?x as Iterator>::Item은 모호한 별칭이므로,<?x as Iterator>::Item을 정규화하면 새로운 추론 변수?y가 생성됩니다- 최종적으로
Foo<?x>를 정규화한 결과는?y입니다
정규화하는 방법
타입 시스템과 상호작용하다 보면 타입의 정규화를 요청해야 하는 경우가 자주 있습니다. 내부 정규화 로직에는 여러 진입점이 있으며, 각 진입점은 컴파일러의 특정 부분에서만 사용해야 합니다.
게다가 현재 컴파일러가 기존 트레이트 솔버에서 새 트레이트 솔버로 전환하는 중이라는 점도 상황을 더 복잡하게 만듭니다. 이 전환의 일환으로 컴파일러의 정규화 방식이 상당히 크게 바뀌었으며, 그 결과 일부 정규화 진입점은 “기존 솔버 전용“이 되었고 새 솔버가 안정화되면 장기적으로 제거될 예정입니다. 이 전환 과정은 GitHub의 WG-trait-system-refactor 레이블(트레이트 시스템 리팩터링 작업 그룹)을 통해 추적할 수 있습니다.
다음은 컴파일러에서 정규화를 수행하는 여러 진입점을 대략적으로 정리한 것입니다.
infcx.at.structurally_normalizeinfcx.at.(deeply_)?normalizeinfcx.query_normalizetcx.normalize_erasing_regionstraits::normalize_with_depth(_to)EvalCtxt::structurally_normalize
트레이트 솔버 외부
InferCtxt 타입은 분석 중에 정규화하는 “주요” 방법인 normalize, deeply_normalize, structurally_normalize를 제공합니다. 이 함수들은 FnCtxt나 ObligationCtxt 같은 다양한 InferCtxt 래퍼 타입에서 래핑되어 다시 제공되는 경우가 많으며, 이때 일부 인자나 반환 타입의 일부를 자동으로 처리하도록 API가 약간 조정되어 있습니다.
구조적 InferCtxt 정규화
infcx.at.structurally_normalize는 추론 변수와 리전을 처리할 수 있는 구조적 정규화를 제공합니다. 일반적으로 타입의 종류를 검사할 때마다 이 함수를 사용해야 합니다.
HIR 타입 검사 내부에는 이와 관련된 정규화 메서드인 fcx.structurally_resolve가 있으며, 이 메서드는 해석하려는 타입이 아직 해결되지 않은 추론 변수이면 오류를 발생시킵니다. 새 솔버가 활성화된 경우에는 이 메서드가 해당 타입의 구조적 정규화도 시도합니다.
이 때문에 HIR 타입 검사에는 먼저 normalize로 타입을 정규화한 다음(기존 솔버에서만 정규화됨) structurally_resolve를 적용하는(새 솔버에서만 정규화됨) 패턴이 있습니다. structurally_resolve는 목표를 평가하여 추론을 진전시키려고 시도하지만 structurally_normalize는 그렇지 않으므로, HIR 타입 검사 중에는 structurally_normalize를 호출하기보다 이 패턴을 사용하는 것이 좋습니다.
깊은 InferCtxt 정규화
infcx.at.(deeply_)?normalize
InferCtxt로 깊은 정규화를 수행하는 방법에는 normalize와 deeply_normalize 두 가지가 있습니다. 그 이유는 normalize는 기존 솔버에서만 사용되는 “레거시” 정규화 진입점인 반면, deeply_normalize는 장기적으로 깊은 정규화를 수행하는 방법이 될 예정이기 때문입니다. 두 메서드 모두 영역(region)을 처리할 수 있습니다.
새 솔버가 안정화되면 infcx.at.normalize 함수는 제거되고, 모든 코드가 새로운 깊은 정규화 또는 구조적 정규화 메서드로 이전될 것입니다. 이러한 이유로 normalize 함수는 새 솔버에서는 아무 동작도 하지 않으므로, 기존 솔버에서는 정규화가 필요하지만 새 솔버에서는 필요하지 않은 경우에만 사용하기에 적합합니다.
모든 모호한 별칭을 추론 변수로 정규화하는 것은 지원할 수 없으므로2, deeply_normalize를 사용하면 모호한 별칭을 만났을 때3 오류가 발생합니다. deeply_normalize는 일반적으로 모호한 별칭을 만나지 않을 것으로 예상되는 경우, 예를 들어 항목 시그니처의 타입을 다룰 때에만 사용해야 합니다.
infcx.query_normalize
infcx.query_normalize는 매우 드물게 사용됩니다. 이 함수는 normalize_erasing_regions와 거의 동일한 제약(추론 변수를 처리할 수 없고, 진단을 지원하지 않음)을 가지며, 주된 차이점은 라이프타임 정보를 유지한다는 것입니다. 이러한 이유로 거의 모든 상황에서 normalize_erasing_regions가 더 나은 선택입니다. 라이프타임이 지워진 쿼리를 캐싱하므로 더 효율적이기 때문입니다.
실제로 query_normalize는 빌림 검사기에서 정규화에 사용되며, 그 밖의 곳에서는 infcx.normalize보다 성능을 높이기 위한 최적화 수단으로 사용됩니다. 새 솔버가 안정화되면 query_normalize를 컴파일러에서 제거할 수 있을 것으로 예상됩니다. 새 솔버의 정규화 구현은 성능이 충분히 좋아서 query_normalize를 제거해도 성능 회귀가 발생하지 않을 것이기 때문입니다.
tcx.normalize_erasing_regions
normalize_erasing_regions는 일반적으로 컴파일러에서 타입 시스템 분석을 수행하지 않는 부분에서 사용됩니다. 이 정규화 진입점은 추론 변수, 라이프타임, 진단을 전혀 처리하지 않습니다. 린트와 코드 생성은 일반적으로 올바른 형태(well-formed)라고 가정할 수 있는(또는 적어도 그에 대한 오류를 보고할 책임이 없는) 완전히 추론된 별칭을 다루므로 이 진입점을 많이 사용합니다.
트레이트 솔버 내부
traits::normalize_with_depth(_to)와 EvalCtxt::structurally_normalize는 각각 이전 트레이트 솔버와 새 트레이트 솔버의 내부에서만 사용됩니다. 이는 사실상 각 트레이트 솔버가 정규화를 구현하는 내부 로직으로 곧바로 들어가는 원시 진입점입니다. 다른 정규화 진입점은 목표(goal) 순환과 재귀 깊이를 올바르게 처리하지 못하므로 트레이트 해결 내부에서는 사용할 수 없습니다.
언제/어디서 정규화하는가 (이전 솔버 대 새 솔버)
이전 솔버와 새 솔버 사이의 큰 변화 중 하나는 별칭이 언제 정규화되어 있을 것으로 기대하는지에 대한 접근 방식입니다.
기존 솔버
모든 타입은 가능한 한 빨리 정규화될 것으로 기대되므로, 타입 시스템에서 만나는 모든 타입은 경직된(rigid) 타입이거나 추론 변수(나중에 경직된 항으로 추론됨)입니다.
구체적인 예로, 별칭의 동등성은 별칭이 경직되어 있다고 가정하고 별칭의 제네릭 인자를 재귀적으로 동등화하는 방식으로 구현됩니다.
새 솔버
모든 타입은 모호하거나 정규화되지 않은 별칭을 포함할 수 있는 것으로 간주됩니다. 별칭의 정규화가 필요한 연산이 수행될 때마다, 별칭을 정규화하는 것은 해당 로직의 책임입니다. 따라서 ty.kind()에 대해 매칭할 때는 거의 항상 먼저 구조적 정규화를 수행해야 합니다.
구체적인 예로, 별칭의 동등성은 동등화되는 모든 별칭 타입이 경직되어 있다고 가정하는 대신 별칭의 정규화를 스스로 처리할 수 있도록 사용자 정의 목표 종류(PredicateKind::AliasRelate)로 구현됩니다.
이러한 접근 방식에도 불구하고, 성능과 단순성을 위해 writeback 중에는 여전히 깊은 정규화를 수행하므로, MIR의 타입은 여전히 깊은 정규화가 완료되었다고 가정할 수 있습니다.
기존 솔버의 정규화 방식에는 몇 가지 주요 문제가 있었고, 이 문제들 때문에 새 솔버에서 방식을 변경하게 되었습니다:
누락된 정규화 호출
정규화 호출이 누락되는 일이 잦았으며, 그 결과 모든 것이 이미 정규화되어 있다고 가정하는 API에 정규화되지 않은 타입이 전달되었습니다. 모호하거나 정규화되지 않은 별칭을 고정(rigid)된 것으로 취급하면, 별칭끼리 서로 같다고 간주되지 않아 온갖 이상한 오류가 발생하거나, 정규화되지 않은 별칭의 제네릭 인자를 동일시하는 과정에서 예상치 못한 추론 유도가 발생합니다.
매개변수 환경 정규화
또 다른 문제는 기존 솔버에서 ParamEnv를 올바르게 정규화할 수 없었다는 점입니다. 정규화 자체가 올바른 결과를 내기 위해 이미 정규화된 ParamEnv를 요구했기 때문입니다. 자세한 내용은 ParamEnvs에 관한 장을 참고하십시오: Typing/ParamEnvs: 모든 바운드 정규화
고차 랭크 타입 안의 정규화할 수 없는 비고정 별칭
for<'a> fn(<?x as Trait<'a>::Assoc>)와 같은 타입은 기존 솔버의 정규화 방식으로는 올바르게 처리할 수 없습니다.
이를 for<'a> fn(?y)로 정규화하고 for<'a> <?x as Trait<'a>>::Assoc -> ?y를 정규화하는 목표를 등록한다면, <?x as Trait<'a>>::Assoc가 &'a u32로 정규화되는 경우에 오류가 발생합니다. 추론 변수 ?y는 for<'a> 바인더를 인스턴스화할 때 만들어진 플레이스홀더보다 낮은 유니버스에 있게 됩니다.
기존 솔버는 모든 별칭이 경직(rigid)되어 있다고 가정하므로, 별칭을 정규화하지 않은 채로 두는 것 역시 잘못된 처리입니다. 이는 새 솔버가 coherence에서 안정화되기 전까지 건전성 버그였습니다: coherence 중에 프로젝션 치환을 관계 짓는 것은 건전하지 않음.
결국 이는 값 내부의 모든 별칭이 고정되어 있음을 항상 보장할 수는 없다는 것을 의미합니다.
발산하는 별칭의 사용 처리
발산하는 별칭은 모호한 별칭과 마찬가지로 추론 변수로 정규화됩니다. 발산하는 별칭을 정규화하면 트레이트 솔버 순환이 발생하므로, 기존 솔버에서는 항상 오류가 발생합니다. 새 솔버에서는 현재 컨텍스트에서 모든 목표가 성립해야 하는 상황에 이르렀을 때에만 오류가 발생합니다. 예를 들어, HIR 타입 검사 중에 발산하는 별칭을 정규화하면 두 솔버 모두에서 오류가 발생합니다.
별칭의 적격성(well formedness)은 해당 별칭이 발산하지 않을 것을 요구하지 않습니다4. 이는 별칭이 적격한지 검사하는 것만으로는 발산하는 별칭에 대해 오류를 발생시키기에 충분하지 않으며, 실제로 별칭을 정규화하려고 시도해야 한다는 것을 의미합니다.
발산하는 별칭에 대한 오류가 정규화의 부수 효과라는 것은 실제로 오류를 발생시킬지 여부가 매우 임의적이라는 뜻이며, 이제는 정규화하는 곳이 더 적어졌으므로 이전 솔버와 새 솔버 사이에서도 차이가 납니다.
발산하는 별칭에 대한 오류 처리가 임시방편적이어서 “문제“를 일으키는 예시는 다음과 같습니다:
#![allow(unused)]
fn main() {
trait Trait {
type Diverges<D: Trait>;
}
impl<T> Trait for T {
type Diverges<D: Trait> = D::Diverges<D>;
}
struct Bar<T: ?Sized = <u8 as Trait>::Diverges<u8>>(Box<T>);
}
이 예시에서는 발산하는 별칭이 사용되지만, 제네릭 매개변수의 기본값을 명시적으로 정규화하는 일이 없으므로 우연히 오류가 발생하지 않습니다. ?Sized 옵트아웃을 제거하면 <u8 as Trait>::Diverges<u8>: Sized 목표를 정규화하게 되고, 그 부수 효과로 발산하는 별칭에 대한 오류가 발생하므로 오류가 보고됩니다.
이 점에서 상수 별칭은 타입 별칭과 약간 다릅니다. 상수 별칭의 적격성은 (ConstEvaluatable 목표를 통해) 성공적으로 평가될 수 있을 것을 요구합니다. 이는 상수 인자의 적격성을 검사하는 것만으로도 평가에 실패할 경우 오류를 발생시키기에 충분하다는 것을 의미합니다. 이를 타입 별칭에도 도입하는 것이 타당한지, 아니면 상수 별칭이 적격성을 위해 이를 더 이상 요구하지 않아야 하는지는 다소 불분명합니다5.
-
새 솔버에서는 이것이 암묵적으로 수행됩니다 ↩
-
바인더 내부의 모호한 별칭은 추론 변수로 정규화할 수 없으며, 이에 대해서는 뒤에서 더 자세히 다룹니다. ↩
-
바인더 안의 모호한 별칭을 처리하는 방식에는 이전 솔버와 새 솔버 사이에 미묘한 차이가 있습니다. 기존 솔버에서는 고차 타입 내부의 일부 모호한 별칭에 대해 오류를 내지 못하는 반면, 새 솔버는 올바르게 오류를 냅니다. ↩
-
별칭이 발산하지 않는지는 별칭이 완전히 구체화되기 전까지 검사할 수 없으므로, 이는 코드 생성/상수 평가 이전에 별칭의 적격성을 검사할 수 없거나, 단형화 이후에 별칭이 적격한 상태에서 적격하지 않은 상태로 바뀔 수 있다는 것을 의미하게 됩니다. ↩
-
이를 더 이상 하지 않더라도 상수 별칭이 타입 별칭보다 덜 건전해지지는 않을 것이 분명합니다 ↩
타이핑/매개변수 환경
타이핑 환경
타입 시스템과 상호 작용할 때는 트레이트 해결 결과에 영향을 줄 수 있는 몇 가지 변수를 고려해야 합니다. 바로 스코프 내의 where 절 집합, 그리고 컴파일러의 어느 단계에서 타입 시스템 연산이 수행되고 있는지입니다(각각 ParamEnv 구조체와 TypingMode 구조체).
타입 시스템 연산을 수행할 환경이 아직 생성되지 않았을 때는 TypingEnv를 사용하여 필요한 모든 외부 컨텍스트를 하나의 타입으로 묶을 수 있습니다.
타입 시스템 연산을 수행할 컨텍스트(예: ObligationCtxt 또는 FnCtxt)가 생성되고 나면 일반적으로 TypingEnv는 어디에도 저장되지 않습니다. 환경 전체의 속성은 TypingMode뿐이고, ParamEnv는 목표(goal)마다 서로 다른 것을 사용할 수 있기 때문입니다.
매개변수 환경
ParamEnv란 무엇입니까
ParamEnv는 스코프 내 where 절의 목록이며, 일반적으로 특정 항목의 where 절에 대응합니다. 일부 절은 명시적으로 작성되지 않고 ConstArgHasType이나 (일부) 암시적 바운드처럼 clauses_of 쿼리에서 암묵적으로 추가됩니다.
대부분의 경우 ParamEnv는 처음에 param_env 쿼리를 통해 생성되며, 이 쿼리는 주어진 항목의 where 절에서 파생된 ParamEnv를 반환합니다. ParamEnv는 특정 항목에서 파생되지 않은 임의의 절 집합으로도 생성할 수 있습니다. 예를 들어 compare_method_clause_entailment에서는 impl의 where 절과 트레이트 정의에 있는 함수의 where 절로 구성된 혼합 ParamEnv를 생성합니다.
다음과 같은 함수가 있다고 합시다.
#![allow(unused)]
fn main() {
// `foo` would have a `ParamEnv` of:
// `[T: Sized, T: Trait, <T as Trait>::Assoc: Clone]`
fn foo<T: Trait>()
where
<T as Trait>::Assoc: Clone,
{}
}
개념적으로 foo 내부에 있다면(예를 들어 이를 타입 검사하거나 린트하는 경우) 타입 시스템과 상호 작용하는 모든 곳에서 이 ParamEnv를 사용하게 됩니다. 이를 통해 정규화, 제네릭 상수 평가, where 절/목표 증명 등이 T가 크기가 정해져 있다는 점(sized)이나 Trait를 구현한다는 점 등에 의존할 수 있습니다.
좀 더 구체적인 예는 다음과 같습니다.
#![allow(unused)]
fn main() {
// `foo` would have a `ParamEnv` of:
// `[T: Sized, T: Clone]`
fn foo<T: Clone>(a: T) {
// when typechecking `foo` we require all the where clauses on `requires_clone`
// to hold in order for it to be legal to call. This means we have to
// prove `T: Clone`. As we are type checking `foo` we use `foo`'s
// environment when trying to check that `T: Clone` holds.
//
// Trying to prove `T: Clone` with a `ParamEnv` of `[T: Sized, T: Clone]`
// will trivially succeed as bound we want to prove is in our environment.
requires_clone(a);
}
}
또는 컴파일되지 않는 예는 다음과 같습니다.
#![allow(unused)]
fn main() {
// `foo2` would have a `ParamEnv` of:
// `[T: Sized]`
fn foo2<T>(a: T) {
// When typechecking `foo2` we attempt to prove `T: Clone`.
// As we are type checking `foo2` we use `foo2`'s environment
// when trying to prove `T: Clone`.
//
// Trying to prove `T: Clone` with a `ParamEnv` of `[T: Sized]` will
// fail as there is nothing in the environment telling the trait solver
// that `T` implements `Clone` and there exists no user written impl
// that could apply.
requires_clone(a);
}
}
ParamEnv 얻기
타입 시스템과 상호 작용할 때 잘못된 ParamEnv를 사용하면 ICE가 발생하거나, 잘못된 형식의 프로그램이 컴파일되거나, 오류가 나지 않아야 할 때 오류가 발생할 수 있습니다. 올바른 매개변수 환경을 사용하도록 컴파일러를 수정하고 그 과정에서 ICE를 고친 PR의 예로 #82159와 #82067을 참고하십시오.
대부분의 경우 ParamEnv가 필요할 때는 이미 스코프 어딘가나 호출 스택의 상위에 존재하므로, 이를 아래로 전달해야 합니다. 기존 ParamEnv를 찾을 수 있는 곳의 일부 목록은 다음과 같습니다.
- 타입 검사(typeck) 중에는
FnCtxt에param_env필드가 있습니다 - 후기 린트(late lint)를 작성할 때는
LateContext에param_env필드가 있습니다 - 올바른 형식(well-formedness) 검사 중에는
WfCheckingCtxt에param_env필드가 있습니다 - MIR 타입 검사에 사용되는
TypeChecker에는param_env필드가 있습니다 - 차세대 트레이트 솔버에서 모든
Goal에는 목표를 어떤 환경에서 증명할지 지정하는param_env필드가 있습니다. - 기존
TypeRelation을 수정할 때 그것이PredicateEmittingRelation을 구현한다면param_env메서드를 사용할 수 있습니다.
사용할 수 있는 ParamEnv가 스코프 어딘가에 있는지 확실하지 않다면 #t-compiler/help Zulip 채널에 스레드를 열어 보는 것도 좋습니다. 누군가가 ParamEnv를 어디서 얻을 수 있는지 알려 줄 수 있습니다.
ParamEnv를 수동으로 구성하는 것은 일반적으로 어떤 최상위 분석(예: HIR 타입 검사 또는 빌림 검사)을 시작할 때만 필요합니다. 이러한 경우 다음 세 가지 방법으로 할 수 있습니다.
- 주어진 정의와 연관된 환경을 반환하는
tcx.param_env(def_id)쿼리를 호출합니다. ParamEnv::empty로 빈 환경을 생성합니다.ParamEnv::new를 사용하여 임의의 where 절 집합으로 환경을 구성합니다. 그런 다음traits::normalize_param_env_or_error를 호출하여 환경에 있는 모든 where 절의 정규화와 확장을 처리합니다.
대부분의 경우 컴파일러는 특정 정의의 일부로서 분석을 수행하므로, param_env 쿼리를 사용하는 것이 ParamEnv를 구성하는 단연 가장 흔한 방법입니다.
ParamEnv::empty로 빈 환경을 생성하는 것은 일반적으로 코드 생성에서(TypingEnv::fully_monomorphized를 통해 간접적으로) 하거나, 제네릭 매개변수를 전혀 만나지 않을 것으로 예상되는 분석의 일부로서(예: 일관성/고아 검사의 여러 부분)만 수행됩니다.
임의의 where 절 집합으로 환경을 생성하는 것은 대개 불필요하며, 필요한 환경이 소스 코드의 실제 항목에 대응하지 않는 경우에만 수행해야 합니다(예: compare_method_clause_entailment).
ParamEnv의 구성 방식
ParamEnv를 생성하는 것은 사용자가 작성한 대로 항목에 정의된 where 절 목록을 그대로 사용하는 것보다 더 복잡합니다. 슈퍼트레이트를 환경에 확장해 넣는 동시에 모든 별칭을 완전히 정규화해야 합니다. 이 로직은 traits::normalize_param_env_or_error가 처리합니다(이름에 정교화에 관한 언급은 전혀 없지만 그렇습니다).
슈퍼트레이트 확장
fn foo<T: Copy>()와 같은 함수가 있을 때, Copy 트레이트는 Clone 슈퍼트레이트를 가지므로 함수 내부에서 T: Clone을 증명할 수 있기를 원합니다. ParamEnv를 구성할 때는 환경에 있는 모든 트레이트 바운드를 살펴보고, 해당 트레이트에서 발견된 모든 슈퍼트레이트에 대해 새로운 where 절을 ParamEnv에 명시적으로 추가합니다.
구체적인 예로 다음 함수를 들 수 있습니다:
#![allow(unused)]
fn main() {
trait Trait: SuperTrait {}
trait SuperTrait: SuperSuperTrait {}
// `bar`'s unelaborated `ParamEnv` would be:
// `[T: Sized, T: Copy, T: Trait]`
fn bar<T: Copy + Trait>(a: T) {
requires_impl(a);
}
fn requires_impl<T: Clone + SuperSuperTrait>(a: T) {}
}
환경을 확장하지 않는다면 T: Clone이나 T: SuperSuperTrait를 증명할 수 없으므로 requires_impl 호출은 타입 검사에 실패할 것입니다. 실제로는 환경을 확장하므로 bar의 ParamEnv는 실제로 [T: Sized, T: Copy, T: Clone, T: Trait, T: SuperTrait, T: SuperSuperTrait]입니다. 이 덕분에 bar를 타입 검사할 때 T: Clone과 T: SuperSuperTrait를 증명할 수 있습니다.
Clone 트레이트에는 Sized 슈퍼트레이트가 있지만, (util::elaborate로 구현된) 정교화 과정이 where 절의 중복을 제거하므로 환경에 T: Sized 바운드가 두 개(슈퍼트레이트에서 온 것 하나와 암묵적으로 추가된 T: Sized 바운드 하나) 생기지는 않습니다.
이로 인한 부수 효과로, 슈퍼트레이트의 정교화가 실제로 일어나지 않더라도 환경에 있는 기존 where 절도 중복이 제거됩니다. 다음 예를 참고하십시오:
#![allow(unused)]
fn main() {
trait Trait {}
// The unelaborated `ParamEnv` would be:
// `[T: Sized, T: Trait, T: Trait]`
// but after elaboration it would be:
// `[T: Sized, T: Trait]`
fn foo<T: Trait + Trait>() {}
}
차세대 트레이트 솔버도 이 정교화가 이루어져야 합니다.
모든 바운드 정규화
기존 트레이트 솔버에서는 ParamEnv에 저장된 where 절이 완전히 정규화되어 있어야 합니다. 그렇지 않으면 트레이트 솔버가 올바르게 동작하지 않습니다. ParamEnv를 정규화해야 하는 구체적인 예는 다음과 같습니다:
#![allow(unused)]
fn main() {
trait Trait<T> {
type Assoc;
}
trait Other {
type Bar;
}
impl<T> Other for T {
type Bar = u32;
}
// `foo`'s unnormalized `ParamEnv` would be:
// `[T: Sized, U: Sized, U: Trait<T::Bar>]`
fn foo<T, U>(a: U)
where
U: Trait<<T as Other>::Bar>,
{
requires_impl(a);
}
fn requires_impl<U: Trait<u32>>(_: U) {}
}
사람은 <T as Other>::Bar가 u32와 같다는 것을 알 수 있으므로, U에 대한 트레이트 바운드가 U: Trait<u32>와 동등하다는 것을 압니다. 그러나 실제로 이 환경에서 기존 솔버로 U: Trait<u32>를 증명하려고 하면, <T as Other>::Bar가 u32와 같다는 것을 판단할 수 없으므로 실패합니다.
이 문제를 우회하기 위해 ParamEnv를 구성한 후 정규화하며, 그 결과 foo의 ParamEnv는 실제로 [T: Sized, U: Sized, U: Trait<u32>]가 됩니다. 이는 이제 트레이트 솔버가 ParamEnv에 있는 U: Trait<u32>를 사용하여 트레이트 바운드 U: Trait<u32>가 성립한다고 판단할 수 있다는 뜻입니다.
연관 타입을 정규화하려면 ParamEnv가 필요하여 부트스트래핑 문제가 생기므로, 이 우회 방법이 모든 경우에 통하지는 않습니다. 정규화가 올바른 결과를 내려면 정규화된 ParamEnv가 필요하지만, 그 ParamEnv를 얻으려면 정규화를 해야 합니다. 현재는 정규화되지 않은 매개변수 환경을 사용하여 ParamEnv를 한 번 정규화하며, 이 방식이 깨지는 예가 일부 있기는 하지만(예시) 실제로는 대체로 괜찮은 결과를 냅니다.
차세대 트레이트 솔버에는 ParamEnv의 모든 where 절이 완전히 정규화되어야 한다는 요구 사항이 없으므로, ParamEnv를 구성할 때 정규화하지 않습니다.
타이핑 모드
타입 시스템 연산을 어떤 맥락에서 수행하는지에 따라 다른 동작이 필요할 수 있습니다. 예를 들어 일관성(coherence) 검사 중에는 목표(goal)가 성립하지 않는다고 간주할 수 있는 경우나 타입이 서로 같지 않다고 간주할 수 있는 경우에 대해 더 엄격한 요구 사항이 있습니다.
컴파일러의 타입 시스템 연산이 어떤 “단계“에서 수행되고 있는지는 TypingMode 열거형(enum)으로 추적합니다. TypingMode 열거형의 문서가 상당히 잘 작성되어 있으므로, 여기서 그대로 반복하는 대신 API 문서를 직접 읽어 보시기를 권장합니다.
타입 추론
타입 추론은 표현식의 타입을 자동으로 알아내는 과정입니다.
타입 추론 덕분에 Rust는 타입 표기를 적게 쓰거나 전혀 쓰지 않아도 동작할 수 있으므로, 사용자가 더 편하게 코드를 작성할 수 있습니다.
fn main() {
let mut things = vec![];
things.push("thing");
}
여기서 things의 타입은 things에 넣는 값 때문에 Vec<&str>로 추론됩니다.
이 타입 추론은 표준 Hindley-Milner(HM) 타입 추론 알고리즘을 기반으로 하지만, 서브타이핑, 영역 추론, 고차 랭크 타입(higher-ranked type)을 지원하기 위해 여러 방식으로 확장되었습니다.
용어에 관한 참고 사항
추론 변수는 ?T 표기법으로 나타냅니다. 추론 변수는 존재 변수라고도 부릅니다.
“영역(region)“과 “라이프타임“이라는 용어는 같은 뜻으로 사용합니다. 둘 다 &'a T의 'a를 가리킵니다.
“바운드 영역“이라는 용어는 for<'a> fn(&'a u32)의 'a처럼 함수 시그니처에서 바인딩된 영역을 가리킵니다. 바인딩되지 않은 영역은 “자유(free)” 영역이라고 합니다.
추론 컨텍스트 생성하기
추론 컨텍스트는 다음과 같은 방식으로 생성합니다.
let infcx = tcx.infer_ctxt().build();
// Use the inference context `infcx` here.
infcx의 타입은 InferCtxt<'tcx>이며, 이를 만드는 데 사용한 tcx와 같은 'tcx 라이프타임을 가집니다.
tcx.infer_ctxt 메서드는 실제로는 빌더를 반환합니다. 따라서 infcx를 생성하기 전에 몇 가지 설정을 할 수 있습니다. 자세한 내용은 InferCtxtBuilder를 참고하십시오.
추론 변수
추론 컨텍스트의 주된 목적은 여러 추론 변수를 담아 두는 것입니다. 추론 변수는 정확한 값이 아직 알려지지 않았지만 타입 검사를 진행하면서 밝혀질 타입이나 영역을 나타냅니다.
H-M 타입 시스템이나 Prolog 같은 논리 언어에서 쓰는 단일화(unification)의 기본 개념을 알고 있다면, 이것도 같은 개념입니다. 그렇지 않다면 H-M 타입 추론의 동작 방식을 다루는 튜토리얼이나 Chalk 프로젝트의 단일화에 관한 이 블로그 글을 읽어 보는 것이 좋습니다.
종합하면, 추론 컨텍스트는 다섯 종류의 추론 변수를 저장합니다( 2023년 3월 기준):
- 타입 변수는 세 종류로 나뉩니다.
- 일반 타입 변수(가장 흔한 종류)입니다. 이 변수는 어떤 타입과도 단일화될 수 있습니다.
- 정수 타입 변수는 정수 타입과만 단일화될 수 있으며,
22같은 정수 리터럴 표현식에서 생겨납니다. - 부동소수점 타입 변수는 부동소수점 타입과만 단일화될 수 있으며,
22.0같은 부동소수점 리터럴 표현식에서 생겨납니다.
- 영역 변수는 라이프타임을 나타내며, 곳곳에서 생겨납니다.
- 상수 변수는 상수를 나타냅니다.
모든 타입 변수는 거의 같은 방식으로 동작합니다. 새 타입 변수를 생성하면, 아직 해결되지 않은 타입 ?T를 나타내는 Ty<'tcx>를 얻습니다. 그런 다음 동등성이나 서브타이핑처럼 추론기가 지원하는 여러 연산을 적용할 수 있으며, 그 결과로 추론기가 해당 ?T를 특정 값으로 인스턴스화(또는 바인딩)할 수도 있습니다.
영역 변수는 다소 다르게 동작하며, 아래의 별도 절에서 설명합니다.
동등성 / 서브타이핑 강제하기
타입 추론기에서 수행할 수 있는 가장 기본적인 연산은 동등성이며, 이는 두 타입 T와 U가 같도록 강제합니다. 동등성 제약을 추가하는 권장 방법은 대략 다음과 같이 at 메서드를 사용하는 것입니다.
infcx.at(...).eq(t, u);
첫 번째 at() 호출은 약간의 맥락, 즉 이 단일화(unification)를 왜 수행하는지와 어떤 환경에서 수행하는지를 제공하며, eq 메서드가 실제 동등성 제약을 수행합니다.
무언가를 동일시하면 그것들이 정확히 같도록 강제하게 됩니다. 동일시는 InferResult를 반환합니다. Err(err)를 반환하면 동일시가 실패한 것이며, 감싸고 있는 TypeError가 무엇이 잘못되었는지 알려 줍니다.
성공하는 경우가 아마 더 흥미로울 것입니다. eq의 “주된” 반환 타입은 ()입니다. 즉, 성공하더라도 특별히 관심을 가질 만한 값을 반환하지 않습니다. 오히려 이 메서드는 타입 변수를 제약하는 등의 부수 효과를 위해 실행됩니다. 그러나 실제 반환 타입은 ()가 아니라 InferOk<()>입니다. InferOk 타입은 추가적인 트레이트 의무(obligation)를 전달하는 데 사용됩니다. 이러한 의무가 이행되도록 보장하는 것은 여러분의 몫입니다(일반적으로 이행 컨텍스트(fulfillment context)에 등록하는 방식으로 합니다). 이에 대한 더 자세한 배경은 트레이트 장을 참조하십시오.
마찬가지로 infcx.at(..).sub(..)를 통해 서브타이핑을 강제할 수 있습니다. 위와 동일한 기본 개념이 적용됩니다.
동등성 “시도하기”
때로는 두 타입을 오류 없이 동일시하는 것이 가능한지 알고 싶을 때가 있습니다. 이는 infcx.can_eq(서브타이핑의 경우 infcx.can_sub)로 테스트할 수 있습니다. 이것이 Ok를 반환하면 동등성이 가능한 것입니다. 하지만 어떤 경우든 모든 부수 효과는 되돌려집니다.
다만 이러한 메서드의 성공 또는 실패는 항상 영역을 제외한(modulo regions) 것이라는 점에 유의하십시오. 즉, 두 타입 &'a u32와 &'b u32는 'a != 'b인 경우에도 can_eq에 대해 Ok를 반환합니다. 이는 영역 제약을 해결하는 방식이 “2단계“로 이루어진다는 특성에서 비롯됩니다.
스냅숏
앞의 can_eq 절에서 설명했듯이, 일련의 연산을 수행한 뒤 그 부수 효과를 되돌릴 수 있으면 유용한 경우가 많습니다. 이렇게 하는 이유는 여러 가지입니다. 그중 하나는 어떤 경로를 택할지 정하기 전에 여러 가능성을 시도해 보면서 백트래킹할 수 있도록 하기 위함입니다. 또 다른 이유는 일련의 작은 변경이 원자적으로 일어나거나 전혀 일어나지 않도록 보장하기 위함입니다.
이를 가능하게 하기 위해 추론 컨텍스트는 snapshot 메서드를 지원합니다. 이 메서드를 호출하면 여러분이 수행하는 연산에서 발생하는 변경 사항을 기록하기 시작합니다. 작업을 마치면 해당 변경 사항을 취소하는 rollback_to를 호출하거나, 변경 사항을 영구적으로 만드는 confirm을 호출할 수 있습니다. 스택과 같은 규칙을 따르기만 하면 스냅숏은 중첩될 수 있습니다.
스냅숏을 직접 사용하기보다는 더 높은 수준의 패턴을 캡슐화한 commit_if_ok나 probe 같은 메서드를 사용하는 것이 도움이 되는 경우가 많습니다.
서브타이핑 의무
짚어 볼 만한 것 중 하나는 서브타이핑 의무입니다. ?T <: i32처럼 두 타입 사이에 서브타입 관계를 강제하는 경우, 이를 동등성 제약으로 변환할 수 있는 경우가 많습니다. 이는 Rust의 서브타이핑 개념이 상당히 제한적이기 때문입니다. 따라서 위의 경우 ?T <: i32는 ?T = i32와 동등합니다.
그러나 어떤 경우에는 더 신중해야 합니다. 예를 들어 영역이 관련된 경우가 그렇습니다. 따라서 ?T <: &'a i32가 있다면, 먼저 &'a i32를 영역 변수를 가진 타입인 &'?b i32로 “일반화“한 다음, ?T를 그 타입과 단일화합니다(?T = &'?b i32). 그런 다음 이 새 변수를 원래의 경계와 관계 짓습니다:
&'?b i32 <: &'a i32
그 결과 '?b: 'a라는 영역 제약(아래 참조)이 생깁니다.
마지막으로 흥미로운 경우는 ?T <: ?U처럼 바인딩되지 않은 두 타입 변수를 관계 짓는 경우입니다. 이 경우에는 더 진행할 수 없으므로 Subtype(?T, ?U)라는 의무를 큐에 넣고 InferOk 메커니즘을 통해 반환합니다. ?T 또는 ?U에 대해 더 자세한 정보가 알려지면 다시 시도해야 합니다.
영역 제약
영역은 타입과는 다소 다른 방식으로 추론됩니다. 즉시 단일화하는 대신 진행하면서 제약을 수집하기만 하며, 영역을 풀려는 시도는 (거의) 하지 않습니다. 이러한 제약은 “outlives” 제약의 형태를 가집니다:
'a: 'b
실제 코드는 이를 하위 영역 관계로 보는 경향이 있지만, 같은 개념입니다:
'b <= 'a
(“verifys“와 같은 다양한 다른 종류의 제약도 있습니다. 자세한 내용은 region_constraints 모듈을 참조하십시오.)
어느 정도 즉시 단일화를 수행하는 경우가 하나 있습니다. 두 영역 사이에 동등성 제약이 있다면
'a = 'b
그 사실을 단일화 테이블에 기록합니다. 그런 다음 opportunistic_resolve_var를 사용하여 'b를 'a로(또는 그 반대로) 변환할 수 있습니다. 이는 고정점 알고리즘의 종료를 보장하기 위해 필요할 때가 있습니다.
영역 제약 풀기
영역 제약은 다른 모든 제약이 알려지고 다른 모든 의무가 증명된 후, 타입 검사의 맨 마지막에야 풀립니다. 현재 영역 제약을 푸는 방법에는 어휘적(lexical) 방법과 비어휘적(non-lexical) 방법의 두 가지가 있습니다. 결국에는 하나만 남게 될 것입니다.
여기서 예외는 leak-check로, 트레이트 해결 중에 사용되며 고차 영역을 포함하는 영역 제약에 의존합니다. 루트 유니버스의 영역 제약(즉, for<'a>에서 비롯되지 않은 것)은 트레이트 시스템에 영향을 주어서는 안 됩니다. 이러한 영역은 코드 생성 중에 모두 지워지기 때문입니다.
어휘적 영역 제약을 풀려면 resolve_regions_and_report_errors를 호출하십시오. 이는 영역 제약 과정을 “닫고” lexical_region_resolve 코드를 호출합니다. 이 작업이 끝난 뒤에 동등화하거나 서브타이핑 관계를 만들려고 하면 모두 ICE가 발생합니다.
NLL 솔버(실제로는 MIR 타입 검사기)는 약간 다른 방식으로 동작합니다. 이 검사기는 트레이트 해결에 정준 쿼리를 사용하며, 정준 쿼리는 마지막에 take_and_reset_region_constraints를 사용합니다. 이는 정준 쿼리 중에 추가된 모든 outlives 제약을 추출합니다. NLL 솔버는 어떤 영역이 서로보다 오래 사는지뿐만 아니라 어디에서 그러한지도 알아야 하므로 이 작업이 필요합니다. 마지막으로 NLL 솔버는 get_region_var_infos를 호출하여 모든 영역 변수를 솔버에 제공합니다.
어휘 영역 해석
어휘 영역 해석은 먼저 각 영역 변수에 빈 값을 할당하는 것으로 시작합니다. 그런 다음 각 outlives 제약 조건을 반복적으로 처리하면서 고정점에 도달할 때까지 영역 변수를 확장합니다. 영역 변수는 영역 격자(lattice)에서의 최소 상계(least-upper-bound) 관계를 사용하여 비교적 간단한 방식으로 확장할 수 있습니다.
트레이트 해석(기존 방식)
이 장에서는 트레이트 해결의 일반적인 과정을 설명하고, 쉽게 드러나지 않는 몇 가지 사항을 짚어 봅니다.
참고: 이 장(과 그 하위 장)은 트레이트 솔버가 현재 어떻게 동작하는지 설명합니다. 하지만 저희는 새 트레이트 솔버를 설계하고 있습니다. 그것에 대해 읽고 싶으시다면 이 하위 장을 참고하십시오.
주요 개념
트레이트 해석은 트레이트에 대한 각 참조를 impl과 짝짓는 과정입니다. 예를 들어 다음과 같은 제네릭 함수가 있고:
fn clone_slice<T:Clone>(x: &[T]) -> Vec<T> { ... }
그 함수를 다음과 같이 호출한다면:
let v: Vec<isize> = clone_slice(&[1, 2, 3])
(이 경우) isize : Clone의 impl이 존재하는지 알아내는 것이 트레이트 해석이 하는 일입니다.
제네릭 함수와 같은 일부 경우에는 구체적인 impl을 찾지 못할 수도 있지만, 호출자가 impl을 제공해야 한다는 사실은 알아낼 수 있다는 점에 유의하십시오. 예를 들어 clone_slice의 본문을 살펴보십시오:
fn clone_slice<T:Clone>(x: &[T]) -> Vec<T> {
let mut v = Vec::new();
for e in &x {
v.push((*e).clone()); // (*)
}
}
(*)로 표시된 줄은 T(*e의 타입)가 Clone 트레이트를 구현하는 경우에만 유효합니다. 당연히 T가 무엇인지 모르므로 구체적인 impl을 찾을 수는 없습니다. 하지만 T:Clone 바운드에 근거하여 호출자가 제공해야 하는 impl이 존재한다고 말할 수 있습니다.
impl이 필요한 트레이트 참조를 가리켜 의무(obligation)라는 용어를 사용합니다. 트레이트 해석 시스템은 기본적으로 적절한 impl이 실제로 존재함을 증명하여 의무를 해석합니다.
타입 검사 중에는 트레이트 선택의 결과를 저장하지 않습니다. 단지 트레이트 선택이 성공하리라는 것만 확인하려는 것입니다. 그런 다음 나중에 코드 생성 시점에 모든 구체 타입을 사용할 수 있게 되면, 트레이트 선택을 다시 수행하여 실제 구현을 고를 수 있으며, 그 구현이 출력 바이너리에 생성됩니다.
개요
트레이트 해석은 세 가지 주요 부분으로 구성됩니다.
-
선택(Selection): 특정 의무를 어떻게 해결할지 결정합니다. 예를 들어 선택 과정은 특정 의무를
Self타입과 일치하는 impl을 사용하여 해결할 수 있다고 결정하거나, 매개변수 바운드(예:T: Trait)를 사용하여 해결할 수 있다고 결정할 수 있습니다. impl의 경우, impl 자체에 있는 where 절 때문에 하나의 의무를 선택하면 중첩된 의무가 생길 수 있습니다. 또한 모호성을 해소하기 위해 그 중첩된 의무들을 평가해야 할 수도 있습니다. -
이행(Fulfillment): 이행 코드는 의무가 완전히 이행되었는지를 추적하는 코드입니다. 기본적으로 이는 선택되어야 할 의무들의 작업 목록입니다. 선택이 성공하면 해당 의무는 작업 목록에서 제거되고 중첩된 의무들이 큐에 추가됩니다. 이행은 추론 변수를 제약합니다.
-
평가(Evaluation): 어떤 추론 변수도 제약하지 않고 의무가 성립하는지 검사합니다. 선택 과정에서 사용됩니다.
선택
선택(selection)은 의무(obligation)를 해결할 수 있는지, 그리고 해결할 수 있다면 어떻게(impl, where 절 등을 통해) 해결할지를 결정하는 과정입니다. 주요 인터페이스는 select() 함수이며, 이 함수는 의무를 받아 SelectionResult를 반환합니다. 가능한 결과는 세 가지입니다.
-
Ok(Some(selection))– 예, 의무를 해결할 수 있으며selection이 그 방법을 나타냅니다. impl을 통해 해결된 경우,selection은 해당 impl이 요구하는 중첩된 의무도 나타낼 수 있습니다. -
Ok(None)– 의무를 해결할 수 있는지 아직 확실하지 않습니다. 이는 의무에 바인딩되지 않은 타입 변수가 포함되어 있을 때 가장 흔히 발생합니다. -
Err(err)– 타입 오류 때문이거나 적용될 수 있는 impl이 전혀 없기 때문에 의무를 확실히 해결할 수 없습니다.
선택의 기본 알고리즘은 후보 수집(candidate assembly)과 확정(confirmation)이라는 두 개의 큰 단계로 나뉩니다.
라이프타임 추론이 작동하는 방식 때문에, 라이프타임 사이의 단일화(unification)나 서브타입 관계가 성립하는지 여부를 즉시 피드백으로 돌려줄 수 없다는 점에 유의하십시오. 따라서 선택 과정에서는 라이프타임 매칭을 고려하지 않습니다. 이는 하위 영역(subregion) 할당이 실패하지 않는다는 사실에 반영되어 있습니다. 이로 인해 나중에 오류로 판명될 라이프타임 제약 조건이 생길 수 있습니다(반면, 라이프타임이 아닌 제약 조건은 선택 과정에서 이미 검사되었으므로 결코 오류를 일으킬 수 없습니다. 물론 이후 단계에서 다른 오류로 이어질 수는 있습니다).
후보 수집
TODO: 서로 다른 후보가 존재하는 이유와, 이것이 왜 프로브(probe) 안에서 일어나야 하는지 설명해야 합니다.
의무를 충족하는 데 사용될 수 있는 impl/where 절 등을 검색합니다. 이들 각각을 후보(candidate)라고 부릅니다. 모호성을 피하기 위해, 확실하게 적용 가능한 후보를 정확히 하나 찾고자 합니다. 경우에 따라 impl/where 절이 적용되는지 여부를 알 수 없을 때도 있습니다. 이는 의무에 바인딩되지 않은 추론 변수가 포함되어 있을 때 발생합니다.
특정 impl/where 절 등이 특정 의무에 적용되는지를 결정하는 서브루틴들을 통틀어 매칭 과정이라고 부릅니다. impl 후보의 경우, 이는 중첩된 의무를 무시하면서 impl 헤더(Self 타입과 트레이트 인자)를 단일화하는 것에 해당합니다. 매칭에 성공하면 이를 후보 집합에 추가합니다. Copy, Sized, CoerceUnsized와 같은 내장 트레이트에 대한 후보를 수집할 때는 다른 규칙이 적용됩니다.
이 첫 번째 단계가 끝나면 후보 집합을 검토할 수 있습니다. 집합의 원소가 하나뿐이라면 작업이 끝납니다. 이것이 스코프 내에서 적용될 수 있는 유일한 impl이기 때문입니다. 그렇지 않다면 where 절과 기타 조건을 사용하여 후보 집합을 선별할 수 있습니다. 선별 과정에서는 evaluate_candidate를 사용하여 중첩된 의무가 적용될 수 있는지 검사합니다. 그래도 후보가 2개 이상 남으면 fn candidate_should_be_dropped_in_favor_of를 사용하여 일부 후보를 다른 후보보다 우선합니다.
이렇게 줄어든 집합에서 모호하지 않은 단일 항목이 나오면 그대로 진행하면 되고, 그렇지 않으면 결과를 모호한 것으로 간주합니다.
선별: 모호성 해소
그런데 모든 타입이 단일화되는 impl이 여러 개 있다면 어떻게 됩니까? 다음 예제를 살펴보십시오.
trait Get {
fn get(&self) -> Self;
}
impl<T: Copy> Get for T {
fn get(&self) -> T {
*self
}
}
impl<T: Get> Get for Box<T> {
fn get(&self) -> Box<T> {
Box::new(<T>::get(self))
}
}
예를 들어 get(&Box::new(1_u16))를 호출하면 어떻게 됩니까? 이 경우 Self 타입은 Box<u16>입니다. 첫 번째 impl은 모든 타입 T에 적용되고 두 번째 impl은 모든 Box<T>에 적용되므로, 이 타입은 두 impl 모두와 단일화됩니다. 이를 모호하지 않게 만들기 위해 컴파일러는 where 절을 고려하여 후보를 제거하려고 시도하는 선별(winnowing) 패스를 수행합니다. 이 경우 첫 번째 impl은 Box<u16> : Copy가 성립할 때만 적용되는데, 이는 성립하지 않습니다. 따라서 선별을 마치면 후보가 하나만 남으므로 계속 진행할 수 있습니다.
where 절
impl 외에 의무(obligation)를 해결하는 또 다른 주요 방법은 where 절을 이용하는 것입니다. 선택 과정에는 항상 where 절 목록을 담은 매개변수 환경이 주어지며, 이 where 절들은 기본적으로 충족 가능하다고 가정할 수 있는 의무입니다. 우리는 그 목록을 순회하면서 현재 의무를 그 목록에서 찾을 수 있는지 확인합니다. 찾을 수 있다면 그 의무는 충족된 것으로 간주합니다. 더 정확히 말하면, 같은 트레이트(또는 어떤 하위 트레이트)에 대한 where 절 의무 중 해당 의무와 매칭될 수 있는 것이 있는지 확인하려는 것입니다.
다음의 간단한 예제를 살펴보십시오.
trait A1 {
fn do_a1(&self);
}
trait A2 : A1 { ... }
trait B {
fn do_b(&self);
}
fn foo<X:A2+B>(x: X) {
x.do_a1(); // (*)
x.do_b(); // (#)
}
foo의 본문에서는 분명히 변수 x에 대해 A1, A2, B의 메서드를 사용할 수 있습니다. (*)로 표시된 줄은 X: A1 의무를 발생시키고, (#)로 표시된 줄은 X: B 의무를 발생시킵니다. 한편 매개변수 환경에는 X : A2와 X : B라는 두 개의 where 절이 담깁니다. 그러면 각 의무에 대해 이 where 절 목록을 검색합니다. X: B 의무는 where 절 X: B와 자명하게 매칭됩니다. X:A1 의무를 해결하려면 X:A2가 X:A1을 함의한다는 점에 주목하면 됩니다.
확인
확인(confirmation)은 트레이트의 출력 타입 매개변수를 의무에서 찾은 값과 단일화하며, 이 과정에서 타입 오류가 발생할 수 있습니다.
이전 절의 Convert 예제를 다음과 같이 변형했다고 가정해 봅시다.
trait Convert<Target> {
fn convert(&self) -> Target;
}
impl Convert<usize> for isize { ... } // isize -> usize
impl Convert<isize> for usize { ... } // usize -> isize
let x: isize = ...;
let y: char = x.convert(); // NOTE: `y: char` now!
impl은 Target이 usize라고 지정했지만 의무는 char를 보고했으므로, 오류가 보고되는 곳은 바로 확인 단계입니다. 따라서 선택의 결과는 오류가 됩니다.
후보 impl은 Self 타입을 기준으로 선택되지만, 확인은 (이 경우) Target 타입 매개변수를 기준으로 수행된다는 점에 유의하십시오.
코드 생성 중의 선택
앞서 언급했듯이 타입 검사 중에는 트레이트 선택의 결과를 저장하지 않습니다. 코드 생성 시점에는 각 메서드 호출에 대해 특정 impl을 고르기 위해 트레이트 선택을 다시 수행합니다. 이는 fn codegen_select_candidate를 사용하여 수행됩니다. 각 해석이 특정 impl로 귀결된다는 것을 알고 있으므로, 이 두 번째 선택에서는 어떤 where 절도 스코프 안에 있는 것으로 간주하지 않습니다.
한 가지 흥미로운 점은 중첩된 의무(nested obligation)와 관련이 있습니다. 일반적으로 코드 생성에서는 어떤 후보가 적용되는지만 알아내면 되며, 중첩된 의무는 이미 참이라고 가정되므로 신경 쓰지 않습니다. 그럼에도 불구하고 현재는 이 의무들을 모두 실제로 이행합니다. 이렇게 하면 때때로 타입 추론 결과에 필요한 정보를 얻을 수 있기 때문입니다. 즉, impl의 타입 변수에 대한 완전한 치환을 알 수 없으므로, 모든 것을 알아내려면 트레이트 선택을 실행해야 합니다.
고차 트레이트 바운드
트레이트 해석에서 비교적 미묘한 개념 중 하나는 고차 트레이트 바운드입니다. 이러한 바운드의 예로는 for<'a> MyTrait<&'a isize>가 있습니다. 고차 트레이트 참조에 대한 선택이 어떻게 동작하는지 차례로 살펴봅시다.
기본 매칭과 플레이스홀더 누출
트레이트 Foo가 있다고 가정합시다:
#![allow(unused)]
fn main() {
trait Foo<X> {
fn foo(&self, x: X) { }
}
}
임의의 'a에 대해 Foo<&'a isize>를 구현하는 타입을 요구하는 함수 want_hrtb가 있다고 합시다:
fn want_hrtb<T>() where T : for<'a> Foo<&'a isize> { ... }
이제 임의의 'a에 대해 Foo<&'a isize>를 구현하는 구조체 AnyInt가 있습니다:
struct AnyInt;
impl<'a> Foo<&'a isize> for AnyInt { }
그렇다면 질문은 AnyInt : for<'a> Foo<&'a isize>가 성립하는가 하는 것입니다. 우리는 답이 ’예’이기를 원합니다. 이를 알아내는 알고리즘은 고차 타입에 대한 서브타이핑과 밀접하게 관련되어 있습니다(이는 여기와 SPJ의 논문에도 설명되어 있습니다. 고차 서브타이핑을 이해하고 싶다면 그 논문을 읽어 보기를 권장합니다). 알고리즘은 몇 가지 단계로 이루어집니다:
- 의무에 있는 바운드 영역을 플레이스홀더로 대체합니다.
- impl을 플레이스홀더 의무와 매칭합니다.
- 플레이스홀더 누출을 검사합니다.
그러면 예제를 차근차근 살펴봅시다.
-
가장 먼저 할 일은 의무에 있는 바운드 영역을 플레이스홀더로 대체하여
AnyInt : Foo<&'0 isize>를 얻는 것입니다(여기서'0은 플레이스홀더 영역 #0을 나타냅니다). 이제 한정자가 더 이상 없다는 점에 유의하십시오. 컴파일러 타입의 관점에서 보면, 이는ty::PolyTraitRef에서TraitRef로 바뀌는 것입니다. 그다음 impl로부터TraitRef를 생성하는데, 이때 그 바운드 영역에는 새로운 변수를 사용합니다(그 결과Foo<&'$a isize>를 얻으며, 여기서'$a는'a에 대한 추론 변수입니다). -
다음으로 두 트레이트 참조를 서로 관계 짓고, 그 결과
'0 == '$a라는 제약 조건을 가진 그래프를 얻습니다. -
마지막으로 플레이스홀더 “누출“을 검사합니다 – 누출이란 기본적으로 플레이스홀더 영역을 다른 플레이스홀더 영역이나 impl 매칭 이전부터 존재하던 임의의 영역과 관계 지으려는 모든 시도를 말합니다. 누출 검사는 플레이스홀더 영역에서부터 탐색하여 그 영역과 어떤 식으로든 관련된 영역들의 집합을 찾는 방식으로 수행됩니다. 이를 “오염(taint)” 집합이라고 합니다. 검사를 통과하려면 그 집합은 오직 자기 자신과 impl에서 나온 영역 변수들로만 구성되어야 합니다. 오염 집합에 그 밖의 다른 영역이 포함되어 있으면 매칭은 실패합니다. 이 경우
'0의 오염 집합은{'0, '$a}이므로 검사는 성공합니다.
실패하는 경우를 살펴봅시다. 다음과 같은 구조체도 있다고 가정해 봅시다
struct StaticInt;
impl Foo<&'static isize> for StaticInt;
StaticInt : for<'a> Foo<&'a isize> 의무는 충족되지 않은 것으로 간주되어야 합니다. 검사는 앞에서와 똑같이 시작됩니다. 'a는 플레이스홀더 '0으로 대체되고, impl 트레이트 참조는 Foo<&'static isize>로 인스턴스화됩니다. 이 둘을 관계 지으면 'static == '0과 같은 제약 조건을 얻습니다. 이는 '0의 오염 집합(taint set)이 {'0, 'static}이라는 뜻이며, 따라서 누출 검사(leak check)에 실패합니다.
TODO: 이는 'static이 영역 변수가 아닌데도 오염 집합에 포함되어 있기 때문이 맞습니까?
고차 트레이트 의무
기본적인 매칭이 끝나면 또 다른 흥미로운 주제로 넘어갑니다. 바로 impl 의무를 어떻게 처리하느냐는 것입니다. 여기서는 간단한 예제를 하나 살펴보겠습니다. 트레이트 Foo와 Bar, 그리고 이와 연관된 impl이 있다고 가정해 보십시오:
#![allow(unused)]
fn main() {
trait Foo<X> {
fn foo(&self, x: X) { }
}
trait Bar<X> {
fn bar(&self, x: X) { }
}
impl<X,F> Foo<X> for F
where F : Bar<X>
{
}
}
이제 Baz: for<'a> Foo<&'a isize>라는 의무가 있고 이 impl을 매칭한다고 합시다. 그 결과로 어떤 의무가 생성됩니까? 우리는 Baz: for<'a> Bar<&'a isize>를 얻고자 하는데, 이는 어떻게 이루어집니까?
매칭이 끝나면 X => &'0 isize와 같은 플레이스홀더 치환을 갖게 됩니다. 이 치환을 impl 의무에 적용하면 F : Bar<&'0 isize>를 얻습니다. 플레이스홀더 영역 '0은 계산 밖으로 누출될 수 없으므로, 이를 그대로 사용할 수 없다는 것은 분명합니다.
우리가 하는 일은 '0의 오염 집합에서 '0이 유래한 원래의 바운드 영역(여기서는 'a)으로 되돌아가는 역매핑을 만드는 것입니다. (이 작업은 higher_ranked::plug_leaks에서 수행됩니다.) 누출 검사를 통과했다는 것을 알고 있으므로, 이 오염 집합은 플레이스홀더 영역 자체와 여러 중간 영역 변수만으로 구성됩니다. 그런 다음 트레이트 참조를 순회하면서 그 오염 집합에 속한 모든 영역을 다시 후기 바운드 영역으로 변환하므로, 이 경우 최종적으로 Baz: for<'a> Bar<&'a isize>를 얻게 됩니다.
캐싱과 그에 따른 미묘한 고려 사항
일반적으로 우리는 트레이트 선택의 결과를 캐시하려고 합니다. 이는 다소 복잡한 과정입니다. 그 이유 중 하나는 트레이트 참조에 있는 타입이 모두 완전히 알려지지 않은 경우에도 결과를 캐시할 수 있기를 원하기 때문입니다. 그런 경우 트레이트 선택 과정이 타입 변수에도 영향을 줄 수 있으므로, 선택 과정의 결과를 캐시할 수 있어야 할 뿐만 아니라 타입 변수에 미친 영향도 재현할 수 있어야 합니다.
예시는 다음과 같습니다.
캐시 동작 방식의 기본 개념은 먼저 바인딩되지 않은 모든 추론 변수를 플레이스홀더 버전으로 대체하는 것입니다. 따라서 $t가 바인딩되지 않은 추론 변수인 트레이트 참조 usize : Foo<$t>가 있다면, 이를 usize : Foo<$0>로 대체할 수 있습니다. 여기서 $0은 플레이스홀더 타입입니다. 그런 다음 캐시에서 이를 조회합니다.
캐시 적중이 발생하면, 선택 과정에서 바로 다음에 취할 단계를 그 적중이 알려 줍니다(예: impl #22를 적용하거나 where 절 X : Foo<Y>를 적용).
반면 적중이 없으면 선택 과정을 처음부터 거쳐야 합니다. 가능한 유일한 impl이 def-id가 22인 다음 impl이라는 결론에 도달했다고 가정해 봅시다:
impl Foo<isize> for usize { ... } // Impl #22
그러면 캐시에 usize : Foo<$0> => ImplCandidate(22)를 기록합니다. 다음으로 ImplCandidate(22)를 확정하며, 이는 (부수 효과로) $t를 isize와 단일화합니다.
이제 나중에 어느 시점에서 usize : Foo<$u>를 마주칠 수 있습니다. 이를 플레이스홀더로 대체하면 앞에서와 마찬가지로 usize : Foo<$0>가 되므로, 캐시 조회가 성공하여 ImplCandidate(22)를 얻습니다. ImplCandidate(22)를 확정하면 (부수 효과로) $u가 isize와 단일화됩니다.
where 절과 로컬 캐시 대 전역 캐시
한 가지 미묘한 상호 작용은 트레이트 조회 결과가 스코프 안에 어떤 where 절이 있는지에 따라 달라진다는 점입니다. 그래서 실제로는 로컬 캐시와 전역 캐시, 이렇게 두 개의 캐시가 있습니다. 로컬 캐시는 ParamEnv에 연결되어 있고, 전역 캐시는 tcx에 연결되어 있습니다. 결과가 스코프 안의 where 절에 의존할 수 있는 경우에는 언제나 로컬 캐시를 사용합니다. 어떤 캐시를 사용할지는 select.rs에 있는 pick_candidate_cache 메서드가 결정합니다. 현재는 매우 단순하고 보수적인 규칙을 사용합니다. 스코프 안에 where 절이 하나라도 있으면 로컬 캐시를 사용합니다. 예전에는 더 세밀하게 구분하려고 시도했지만, 그 때문에 #22019와 #18290 같은 성가시고 이상한 버그가 잇따라 발생했습니다. 이 단순한 규칙은 꽤 확실하게 안전해 보이며, 여전히 매우 높은 적중률(rustc를 컴파일할 때 약 95%)을 유지합니다.
TODO: pick_candidate_cache는 더 이상 존재하지 않는 것으로 보입니다. 전반적으로 이 절의 내용이 아직도 정확하기는 합니까?
암시적 바운드
현재는 명시적인 어노테이션을 피하기 위해 함의된 영역 바운드를 추가합니다. 예를 들어 fn foo<'a, T>(x: &'a T)는 T: 'a를 명시하지 않아도 이것이 성립한다고 자유롭게 가정할 수 있습니다.
암시적 바운드에는 명시적 암시적 바운드와 묵시적 암시적 바운드의 두 가지 종류가 있습니다. 명시적 암시적 바운드는 관련 항목의 fn clauses_of에 추가되는 반면, 암시적 바운드는, 음… 암묵적으로 처리됩니다.
명시적 암시적 바운드
명시적 암시적 바운드는 fn inferred_outlives_of에서 계산됩니다. ADT와 CTA만 명시적 암시적 바운드를 가지며, 이는 fn inferred_outlives_crate 쿼리에서 고정점 알고리즘을 통해 계산됩니다.
크레이트에 있는 모든 ADT의 모든 필드에 대해 fn insert_required_clauses_to_be_wf를 사용합니다. 이 함수는 별도의 구현을 사용하여 필드의 각 구성 요소에 대한 outlives 바운드를 계산합니다.
ADT, 트레이트 객체, 연관 타입의 경우 처음에 요구되는 절은 fn check_explicit_clauses에서 계산됩니다. 이 함수는 fn explicit_clauses_of를 정교화(elaborate)하지 않고 그대로 사용합니다.
영역 절은 fn insert_outlives_clause를 통해 추가됩니다. 이 함수는 outlives 절을 받아 분해한 다음, outlive되는 영역이 영역 매개변수인 경우에만 그 구성 요소를 명시적 절로 추가합니다. 'static 요구 사항은 추가하지 않습니다.
묵시적 암시적 바운드
아직 바인더 안의 함의를 처리할 수 없으므로, impl과 함수의 outlives 요구 사항을 단순히 명시적 술어로 추가할 수는 없습니다.
묵시적 암시적 바운드를 가정으로 사용하기
이 바운드는 영향을 받는 항목 자체의 ParamEnv에는 추가되지 않습니다. 어휘적 영역 해석(lexical region resolution)에서는 fn OutlivesEnvironment::from_normalized_bounds를 사용하여 추가됩니다. 마찬가지로 MIR borrowck 중에는 fn UniversalRegionRelationsBuilder::add_implied_bounds를 사용하여 이를 추가합니다.
MIR borrowck에서는 함수 시그니처와 impl 헤더에 대한 암시적 바운드를 추가합니다. MIR borrowck 외부에서는 fn assumed_wf_types 쿼리가 반환하는 타입에 대한 outlives 요구 사항을 추가합니다.
암묵적 바운드에 대해 가정되는 outlives 제약은 fn implied_outlives_bounds 쿼리를 사용하여 계산됩니다. 이 쿼리는 fn wf::obligations에서 필요한 outlives 바운드를 직접 추출합니다.
MIR borrowck는 정규화된 타입과 정규화되지 않은 타입 모두에 대해 outlives 제약을 추가하지만, 어휘 영역 해석은 정규화되지 않은 타입만 사용합니다.
묵시적 암시적 바운드 증명하기
묵시적 암시적 바운드는 fn clauses_of에 포함되지 않으므로, 이들이 실제로 성립하는지 별도로 확인해야 합니다. 일반적으로는 WellFormed 술어를 생성하여 사용된 모든 타입이 적격(well-formed)인지 검사하는 방식으로 이를 처리합니다.
impl을 인스턴스화할 때는 WellFormed 술어를 생성할 수 없습니다. 그렇게 하면 트레이트 솔버 순환(현재는 대개 귀납적인 순환)이 발생하기 때문입니다. 또한 고차 영역의 바인더로부터 얻는 암시적 바운드가 없으므로, 고차 영역이 관련된 제약은 생성하지 않습니다.
이로 인해 여러 건전성 문제가 발생합니다:
특수화
TODO: Chalk는 어디에 해당합니까? 여기에서 이를 언급하거나 논의해야 합니까?
specialize 모듈에 정의되어 있습니다.
기본 전략은 일관성 검사 중에 특수화 그래프를 빌드하는 것입니다(일관성 검사는 겹치는 impl을 찾습니다). 그래프에 삽입할 때 특수화 계층 구조에서 impl을 넣을 올바른 위치를 찾습니다. 올바른 위치가 없는 경우(부분적으로 겹치지만 포함 관계가 없는 경우)에는 겹침 오류가 발생합니다. impl을 선택할 때는 (당연히) 특수화를 참조하며, 특수화 계층 구조 아래로 기본값을 전파할 때는 그래프를 참조합니다.
선택 과정, 즉 실제로 특수화를 수행할 때 특수화 그래프가 사용될 것이라고 예상할 수도 있습니다. 그렇게 하지 않는 데에는 두 가지 이유가 있습니다.
-
이는 단지 최적화일 뿐입니다. 적용 가능한 후보 집합이 주어지면, 그래프를 참조하지 않고도 후보들을 특수화 관점에서 직접 비교하여 가장 특수화된 후보를 결정할 수 있습니다. 선택 결과도 캐시한다는 점을 고려하면, 이 최적화의 이점은 의문스럽습니다.
-
애초에 특수화 그래프를 빌드하려면 선택을 사용해야 합니다(한 impl이 다른 impl을 특수화하는지 판단해야 하기 때문입니다). 이러한 재진입을 처리하려면 선택에 추가적인 모드 전환이 필요합니다. 어차피 그래프를 사용해야 할 강력한 이유가 없어 보이므로, 선택에서는 더 단순한 접근 방식을 유지하고 그래프는 기본 구현을 전파하는 데에만 사용합니다.
트레이트 impl 선택은 여러 impl이 적용될 수 있는 경우에도, 그 impl들이 같은 특수화 계열에 속하기만 하면 성공할 수 있습니다. 이 경우 선택이 성공하면 하나의 impl을 반환합니다. 이는 적용된다고 알려진 impl 중 가장 특수화된 impl입니다. 그러나 추론 변수가 관여하는 경우, 반환된 impl이 코드 생성 시점에 실제로 사용할 impl이 아닐 수도 있습니다. 따라서 (1) 연관 타입이 default를 사용하지 않아 재정의될 수 없거나 (2) 모든 입력 타입이 구체적으로 알려진 경우가 아니라면, 연관 타입을 프로젝션하지 않도록 특별히 주의합니다.
추가 자료
@sunjay의 이 발표가 유용할 수 있습니다. 이 발표는 문제와 해결책에 대한 대략적인 개요만 제공한다는 점을 유념하십시오(이 발표는 @sunjay의 작업이 절반쯤 진행되었을 때 이루어졌습니다). 또한 이 발표는 2018년 6월에 진행되었으므로, 시청하는 시점에는 일부 내용이 바뀌었을 수 있습니다.
Chalk 기반 트레이트 해결
Chalk는 Rust를 위한 실험적인 트레이트 솔버로, ( 2022년 5월 기준) 타입 팀이 개발하고 있습니다. Chalk의 목표는 구현하기 어려운 여러 트레이트 시스템 기능과 버그 수정(예: GAT나 특수화)을 가능하게 하는 것입니다. 새 솔버 개발을 돕고 싶다면 rust-lang Zulip의 #t-types 채널에 들러 인사해 주십시오!
새로운 방식의 트레이트 솔버는 chalk에서 이루어진 작업을 기반으로 합니다. Chalk는 Rust의 트레이트 시스템을 논리 프로그래밍의 관점에서 명시적으로 재구성합니다. 이를 위해 Rust 코드를 일종의 논리 프로그램으로 “로워링(lowering)“하며, 이렇게 만든 프로그램에 쿼리를 실행할 수 있습니다.
여기서 핵심은 Rust 트레이트 시스템이 기본적으로 일종의 논리이며, 표준적인 논리 추론 규칙으로 옮길 수 있다는 점입니다. 그런 다음 예를 들어 Prolog 솔버가 동작하는 방식과 매우 유사한 방식으로 해당 추론 규칙의 해를 찾을 수 있습니다. 알고 보니 Prolog 규칙(혼 절이라고도 함)을 그대로 사용할 수는 없고, 그보다 표현력이 다소 더 높은 변형이 필요합니다.
chalk 자체에 대해서는 Chalk 책 섹션에서 더 자세히 읽을 수 있습니다.
진행 중인 작업
새로운 방식의 트레이트 해결 설계는 두 곳에서 이루어집니다.
chalk. chalk 저장소는 트레이트 시스템에 대한 새로운 아이디어와 설계를 실험하는 곳입니다.
rustc. 논리 규칙이 만족스러워지면 이를 rustc에 구현하는 단계로 넘어갑니다. rustc의 로워링(lowering) 모듈에서 구조체, 트레이트, impl 선언을 논리 추론 규칙으로 매핑합니다.
논리로 로워링하기
여기서 핵심적인 관찰은 Rust 트레이트 시스템이 기본적으로 일종의 논리이며, 표준 논리 추론 규칙으로 대응시킬 수 있다는 점입니다. 그러면 예를 들어 Prolog 솔버가 동작하는 방식과 매우 유사한 방식으로 그 추론 규칙들의 해를 찾을 수 있습니다. 알고 보면 Prolog 규칙(혼 절(Horn clause)이라고도 합니다)을 그대로 사용할 수는 없고, 그보다 표현력이 다소 더 높은 변형이 필요합니다.
Rust 트레이트와 논리
가장 먼저 짚어 볼 점 중 하나는 Rust 트레이트 시스템이 기본적으로 일종의 논리라는 것입니다. 따라서 구조체, 트레이트, impl 선언을 논리 추론 규칙으로 대응시킬 수 있습니다. 대부분의 경우 이것들은 기본적으로 혼 절이지만, Rust의 풍부한 표현력을 온전히 담아내려면(특히 제네릭 프로그래밍을 지원하려면) 표준 혼 절보다 조금 더 나아가야 한다는 것을 보게 될 것입니다.
이 대응이 어떻게 동작하는지 알아보기 위해 예제부터 시작합시다. 다음과 같이 트레이트 하나와 impl 몇 개를 선언한다고 가정해 보십시오.
#![allow(unused)]
fn main() {
trait Clone { }
impl Clone for usize { }
impl<T> Clone for Vec<T> where T: Clone { }
}
이 선언들을 Prolog와 비슷한 표기법으로 작성한 몇 개의 혼 절로 다음과 같이 대응시킬 수 있습니다.
Clone(usize).
Clone(Vec<?T>) :- Clone(?T).
// The notation `A :- B` means "A is true if B is true".
// Or, put another way, B implies A.
Prolog 용어로 말하자면, Clone(Foo)(여기서 Foo는 어떤 Rust 타입입니다)는 타입 Foo가 Clone을 구현한다는 개념을 나타내는 술어라고 할 수 있습니다. 이 규칙들은 프로그램 절이며, 해당 술어가 증명될 수 있는(즉, 참으로 간주되는) 조건을 기술합니다. 따라서 첫 번째 규칙은 단순히 “usize에 대해 Clone이 구현되어 있다“라고 말합니다. 다음 규칙은 “임의의 타입 ?T에 대해, ?T에 clone이 구현되어 있으면 Vec<?T>에 Clone이 구현되어 있다“라고 말합니다. 따라서 예를 들어 Clone(Vec<Vec<usize>>)를 증명하고 싶다면, 다음과 같이 규칙을 재귀적으로 적용하여 증명합니다.
Clone(Vec<Vec<usize>>)는 다음 조건에서 증명 가능합니다.Clone(Vec<usize>)는 다음 조건에서 증명 가능합니다.Clone(usize)가 증명 가능합니다. (실제로 증명 가능하므로 아무 문제가 없습니다.)
그런데 이번에는 Clone(Vec<Bar>)를 증명하려 한다고 가정해 봅시다. 이는 실패합니다(애초에 Bar에 대한 Clone의 impl을 제공하지 않았기 때문입니다).
Clone(Vec<Bar>)는 다음 조건에서 증명 가능합니다.Clone(Bar)가 증명 가능합니다. (하지만 적용 가능한 규칙이 없으므로 증명 가능하지 않습니다.)
위의 예제를 입력 타입이 둘 이상인 제네릭 트레이트까지 다루도록 쉽게 확장할 수 있습니다. 그러면 Self가 타입 T의 값과 동등 비교가 가능함을 선언하는 Eq<T> 트레이트를 생각해 보십시오.
trait Eq<T> { ... }
impl Eq<usize> for usize { }
impl<T: Eq<U>> Eq<Vec<U>> for Vec<T> { }
이는 다음과 같이 대응시킬 수 있습니다.
Eq(usize, usize).
Eq(Vec<?T>, Vec<?U>) :- Eq(?T, ?U).
여기까지는 순조롭습니다.
일반 함수의 타입 검사
자, 이제 트레이트가 언제 구현되는지 표현하고 연관 타입을 처리할 수 있는 논리 규칙들을 정의했으니, 타입 검사로 관심을 조금 돌려 봅시다. 타입 검사가 흥미로운 이유는 바로 타입 검사가 우리가 증명해야 할 목표를 제공하기 때문입니다. 즉, 지금까지 살펴본 모든 내용은 프로그램의 트레이트와 impl로부터 목표를 증명하는 데 쓰이는 규칙을 어떻게 도출하는지에 관한 것이었습니다. 하지만 우리는 증명해야 할 목표를 어떻게 도출하는지에도 관심이 있으며, 그 목표는 타입 검사에서 나옵니다.
여기 있는 함수 foo()의 타입 검사를 생각해 보십시오.
fn foo() { bar::<usize>() }
fn bar<U: Eq<U>>() { }
물론 이 함수는 매우 단순합니다. 하는 일이라고는 bar::<usize>()를 호출하는 것뿐입니다. 이제 bar()의 정의를 보면, where 절 U: Eq<U> 하나가 있음을 알 수 있습니다. 따라서 foo()는 usize를 타입 인자로 하여 bar()를 호출할 수 있음을 보이려면 usize: Eq<usize>임을 증명해야 합니다.
원한다면 bar()를 호출할 수 있는 조건을 정의하는 Prolog 술어를 작성할 수도 있습니다. 이러한 조건을 만족하는 것을 “적격(well-formed)“이라고 부르겠습니다.
barWellFormed(?U) :- Eq(?U, ?U).
그러면 bar::<usize>에 대한 참조(즉, usize 타입에 적용된 bar())가 적격이라면 foo()가 타입 검사를 통과한다고 말할 수 있습니다.
fooTypeChecks :- barWellFormed(usize).
목표 fooTypeChecks를 증명하려고 하면 성공합니다.
fooTypeChecks는 다음 조건이 성립하면 증명 가능합니다.barWellFormed(usize), 이는 다음 조건이 성립하면 증명 가능합니다.Eq(usize, usize), 이는 impl이 있으므로 증명 가능합니다.
좋습니다. 여기까지는 순조롭습니다. 이제 더 복잡한 함수의 타입 검사로 넘어갑시다.
제네릭 함수의 타입 검사: 혼 절을 넘어서
앞 절에서는 (Rust의 타입 동등성 개념으로 보강한) 표준 Prolog 혼 절을 사용하여 몇 가지 간단한 Rust 함수를 타입 검사했습니다. 하지만 이 방법은 제네릭이 아닌 함수, 즉 제네릭 함수가 아닌 함수를 타입 검사할 때만 통합니다. 제네릭 함수를 타입 검사하려면 Prolog가 제공하는 것보다 더 강력한 목표 개념이 필요하다는 것이 드러납니다. 무슨 말인지 알아보기 위해, 앞의 예제를 고쳐 foo를 제네릭으로 만들어 봅시다.
fn foo<T: Eq<T>>() { bar::<T>() }
fn bar<U: Eq<U>>() { }
foo의 본문을 타입 검사하려면 타입 T를 “추상적“으로 유지할 수 있어야 합니다. 즉, foo의 본문이 어떤 특정 타입에 대해서만이 아니라 모든 타입 T에 대해 타입 안전한지 확인해야 합니다. 이를 다음과 같이 표현할 수 있습니다.
fooTypeChecks :-
// for all types T...
forall<T> {
// ...if we assume that Eq(T, T) is provable...
if (Eq(T, T)) {
// ...then we can prove that `barWellFormed(T)` holds.
barWellFormed(T)
}
}.
여기서 사용하는 표기법은 제가 프로토타입 구현에서 사용해 온 표기법으로, 표준 수학 표기법과 비슷하지만 약간 Rust식으로 바꾼 것입니다. 어쨌든 문제는 표준 혼 절이 목표 안에 전칭 양화(forall)나 함의(if)를 허용하지 않는다는 것입니다(많은 Prolog 엔진이 확장 기능으로 이를 지원하기는 합니다). 이러한 이유로 “1차 유전 해롭(first-order hereditary harrop)”(FOHH) 절이라는 것을 받아들여야 합니다. 이 긴 이름은 기본적으로 “본문에 forall과 if가 있는 표준 혼 절“을 의미합니다. 하지만 정식 이름을 알아 두면 좋습니다. FOHH 절을 효율적으로 처리하는 방법을 다룬 연구가 많기 때문입니다. 예를 들어 Chalk Book의 참고 문헌에 있는 Gopalan Nadathur의 훌륭한 논문 “A Proof Procedure for the Logic of Hereditary Harrop Formulas”를 참고하십시오.
FOHH를 지원하는 것은 사실 그리 어렵지 않습니다. 그리고 일단 이를 지원할 수 있게 되면, foo와 같은 제네릭 함수의 타입 검사 규칙을 우리 논리로 쉽게 기술할 수 있습니다.
출처
이 페이지는 Nicholas Matsakis의 블로그 글을 약간 수정한 것입니다.
목표와 절
논리 프로그래밍 용어로, 목표(goal)는 증명해야 하는 것이고 절(clause)은 참이라고 알고 있는 것입니다. 논리로의 로워링 장에서 설명한 것처럼, Rust의 트레이트 솔버는 hereditary harrop(HH) 절을 확장한 것에 기반하며, HH 절은 전통적인 Prolog 혼 절에 몇 가지 새로운 강력한 기능을 더한 것입니다.
목표와 절의 메타 구조
Rust의 솔버에서 목표와 절은 다음과 같은 형태를 가집니다(두 정의가 서로를 참조한다는 점에 유의하십시오).
Goal = DomainGoal // defined in the section below
| Goal && Goal
| Goal || Goal
| exists<K> { Goal } // existential quantification
| forall<K> { Goal } // universal quantification
| if (Clause) { Goal } // implication
| true // something that's trivially true
| ambiguous // something that's never provable
Clause = DomainGoal
| Clause :- Goal // if can prove Goal, then Clause is true
| Clause && Clause
| forall<K> { Clause }
K = <type> // a "kind"
| <lifetime>
이러한 종류의 목표에 대한 증명 절차는 사실 매우 간단합니다. 본질적으로 이는 깊이 우선 탐색의 한 형태입니다. 자세한 내용은 논문 “A Proof Procedure for the Logic of Hereditary Harrop Formulas”에 나와 있습니다.
코드 측면에서 이 타입들은 rustc에서는 rustc_middle/src/traits/mod.rs에, chalk에서는 chalk-ir/src/lib.rs에 정의되어 있습니다.
도메인 목표
도메인 목표는 트레이트 논리의 원자입니다. 위에 제시한 정의에서 볼 수 있듯이, 일반 목표는 기본적으로 도메인 목표들의 조합으로 이루어집니다.
또한 앞서 제시한 절의 정의를 조금 평탄화해 보면, 절은 항상 다음과 같은 형태임을 알 수 있습니다.
forall<K1, ..., Kn> { DomainGoal :- Goal }
따라서 도메인 목표는 사실 절의 좌변(LHS)입니다. 즉, 가장 세분화된 수준에서 도메인 목표는 트레이트 솔버가 최종적으로 증명하려고 시도하게 되는 대상입니다.
우리 시스템의 도메인 목표 집합을 정의하려면 먼저 몇 가지 간단한 공식을 소개해야 합니다. 트레이트 참조(trait reference)는 트레이트의 이름과 그에 알맞은 입력 집합 P0..Pn으로 구성됩니다.
TraitRef = P0: TraitName<P1..Pn>
예를 들어 u32: Display는 트레이트 참조이며, Vec<T>: IntoIterator도 마찬가지입니다. Rust 표면 구문은 연관 타입 바인딩(Vec<T>: IntoIterator<Item = T>)처럼 트레이트 참조에 속하지 않는 몇 가지 추가 요소도 허용한다는 점에 유의하십시오.
프로젝션(projection)은 연관 항목 참조와 그 입력 P0..Pm으로 구성됩니다.
Projection = <P0 as TraitName<P1..Pn>>::AssocItem<Pn+1..Pm>
이를 바탕으로 DomainGoal을 다음과 같이 정의할 수 있습니다.
DomainGoal = Holds(WhereClause)
| FromEnv(TraitRef)
| FromEnv(Type)
| WellFormed(TraitRef)
| WellFormed(Type)
| Normalize(Projection -> Type)
WhereClause = Implemented(TraitRef)
| ProjectionEq(Projection = Type)
| Outlives(Type: Region)
| Outlives(Region: Region)
WhereClause는 Rust 사용자가 Rust 프로그램에서 실제로 작성할 수 있는 where 절을 가리킵니다. 이 추상화는 Rust에서 실제로 작성 가능한 도메인 목표만 다루고 싶을 때가 있기 때문에 오직 편의를 위해 존재합니다.
이제 이들 각각을 하나씩 살펴봅시다.
Implemented(TraitRef)
예: Implemented(i32: Copy)
주어진 트레이트가 주어진 입력 타입과 라이프타임에 대해 구현되어 있으면 참입니다.
ProjectionEq(프로젝션 = 타입)
예: ProjectionEq<T as Iterator>::Item = u8
주어진 연관 타입 Projection은 Type과 같습니다. 이는 정규화를 사용하거나 플레이스홀더 연관 타입을 사용하여 증명할 수 있습니다. Chalk Book의 연관 타입 절을 참조하십시오.
Normalize(프로젝션 -> 타입)
예: ProjectionEq<T as Iterator>::Item -> u8
주어진 연관 타입 Projection을 Type으로 정규화할 수 있습니다.
Chalk Book의 연관 타입 절에서 설명한 것처럼, Normalize는 ProjectionEq를 함의하지만 그 역은 성립하지 않습니다. 일반적으로 Normalize(<T as Trait>::Item -> U)를 증명하려면 Implemented(T: Trait)도 증명해야 합니다.
FromEnv(TraitRef)
예: FromEnv(Self: Add<i32>)
내부의 TraitRef가 참이라고 가정되는 경우, 즉 스코프 내 where 절로부터 유도될 수 있는 경우에 참입니다.
예를 들어, 다음과 같은 함수가 있다고 합시다.
#![allow(unused)]
fn main() {
fn loud_clone<T: Clone>(stuff: &T) -> T {
println!("cloning!");
stuff.clone()
}
}
이 함수의 본문 안에서는 FromEnv(T: Clone)이 성립합니다. 스코프 내 where 절은 중첩되므로, impl 본문 안에 있는 함수 본문은 impl 본문의 where 절도 상속합니다.
이 규칙과 다음 규칙은 암시적 바운드를 구현하는 데 사용됩니다. 로워링 절에서 살펴보겠지만, FromEnv(TraitRef)는 Implemented(TraitRef)를 함의하지만 그 역은 성립하지 않습니다. 이 구분은 암시적 바운드에 매우 중요합니다.
FromEnv(Type)
예: FromEnv(HashSet<K>)
내부의 Type이 적격하다고 가정되는 경우, 즉 함수나 impl의 입력 타입인 경우 참입니다.
예를 들어, 다음 코드가 있다고 합시다.
struct HashSet<K> where K: Hash { ... }
fn loud_insert<K>(set: &mut HashSet<K>, item: K) {
println!("inserting!");
set.insert(item);
}
HashSet<K>는 loud_insert 함수의 입력 타입입니다. 따라서 우리는 이것이 적격하다고 가정하며, 그러므로 함수 본문 내부에서 FromEnv(HashSet<K>)를 가지게 됩니다. 로워링 절에서 살펴보겠지만, HashSet 선언이 K: Hash where 절과 함께 작성되었기 때문에 FromEnv(HashSet<K>)는 Implemented(K: Hash)를 함의합니다. 따라서 loud_insert 함수에 그 바운드를 반복해서 적을 필요가 없습니다. 오히려 그 바운드가 참이라고 자동으로 가정합니다.
WellFormed(Item)
이 목표들은 주어진 항목이 적격하다는 것을 함의합니다.
다양한 종류의 항목이 적격하다는 것에 대해 이야기할 수 있습니다.
-
타입: 예를 들어
WellFormed(Vec<i32>)는 Rust에서 참이지만,WellFormed(Vec<str>)는 참이 아닙니다(str이Sized가 아니기 때문입니다). -
TraitRef: 예를 들어
WellFormed(Vec<i32>: Clone)이 있습니다.
적격성은 암시적 바운드에 중요합니다. 특히 loud_clone 예제에서 FromEnv(T: Clone)을 가정해도 괜찮은 이유는 loud_clone의 각 호출 지점마다 WellFormed(T: Clone)도 검증하기 때문입니다. 마찬가지로 loud_insert 예제에서 FromEnv(HashSet<K>)를 가정해도 괜찮은 이유는 loud_insert의 각 호출 지점마다 WellFormed(HashSet<K>)를 검증하기 때문입니다.
Outlives(Type: Region), Outlives(Region: Region)
예: Outlives(&'a str: 'b), Outlives('a: 'static)
왼쪽에 주어진 타입 또는 리전이 오른쪽 리전보다 오래 살아남으면 참입니다.
공귀납적 목표
우리 시스템의 목표는 대부분 “귀납적“입니다. 귀납적 목표에서는 순환 추론이 허용되지 않습니다. 다음 예시 절을 살펴보십시오.
Implemented(Foo: Bar) :-
Implemented(Foo: Bar).
귀납적으로 고려하면 이 절은 쓸모가 없습니다. Implemented(Foo: Bar)를 증명하려고 하면 재귀적으로 Implemented(Foo: Bar)를 증명해야 하고, 그 순환은 끝없이 계속될 것이기 때문입니다(트레이트 솔버는 여기서 종료되며, 단지 Implemented(Foo: Bar)가 참인지 알 수 없다고 간주할 것입니다).
그러나 일부 목표는 공귀납적입니다. 간단히 말해, 이는 순환이 허용된다는 뜻입니다. 따라서 Bar가 공귀납적 트레이트라면 위의 규칙은 완벽하게 유효하며, Implemented(Foo: Bar)가 참임을 나타냅니다.
자동 트레이트는 Rust에서 공귀납적 목표가 사용되는 한 가지 예입니다. Send 트레이트를 살펴보고, 다음과 같은 구조체가 있다고 가정해 보십시오.
#![allow(unused)]
fn main() {
struct Foo {
next: Option<Box<Foo>>
}
}
자동 트레이트의 기본 규칙에 따르면, Foo의 필드 타입들이 Send이면 Foo는 Send입니다. 따라서 다음과 같은 규칙이 생깁니다.
Implemented(Foo: Send) :-
Implemented(Option<Box<Foo>>: Send).
짐작하시겠지만, Option<Box<Foo>>: Send를 증명하다 보면 결국 순환적으로 Foo: Send를 다시 증명해야 하게 됩니다. 따라서 이는 순환에 빠지게 되는 예이지만, 괜찮습니다. Foo: Send가 자기 자신을 참조하더라도 우리는 이것이 성립한다고 실제로 간주합니다.
일반적으로 공귀납적 트레이트는 Rust 트레이트 해결에서 고정된 가능성의 집합을 열거하고자 할 때 사용됩니다. 자동 트레이트의 경우, 주어진 시작점에서 도달 가능한 타입의 집합을 열거합니다(즉, Foo는 Option<Box<Foo>> 타입의 값에 도달할 수 있고, 이는 Box<Foo> 타입의 값에, 그리고 다시 Foo 타입의 값에 도달할 수 있음을 의미하며, 이로써 순환이 완성됩니다).
자동 트레이트 외에도 WellFormed 술어는 공귀납적입니다. 이들은 암시적 바운드에 관한 절에서 설명한 것처럼, 비슷한 “모든 경우 열거” 패턴을 구현하는 데 사용됩니다.
미완성 장
아직 작성되지 않은 주제는 다음과 같습니다.
- 증명 절차에 대한 상세 설명
- SLG 해결 – 부정 추론 도입
정준 쿼리
트레이트 시스템의 “시작점“은 정준 쿼리입니다(이는 일반적인 의미의 쿼리, 즉 답을 알고 싶은 무언가라는 의미이기도 하고, rustc 특유의 의미의 쿼리이기도 합니다). 타입 검사기나 시스템의 다른 부분이 자신의 작업을 수행하는 과정에서 어떤 타입에 대해 어떤 트레이트가 구현되어 있는지(예: u32: Debug가 참인지) 알고 싶어 할 수 있다는 것이 핵심입니다. 또는 어떤 연관 타입을 정규화하고 싶어 할 수도 있습니다.
이 절에서는 상당히 높은 추상화 수준에서 쿼리를 다룹니다. 하위 절에서는 이러한 아이디어가 rustc에서 어떻게 구현되는지 좀 더 자세히 살펴봅니다.
전통적인 대화형 Prolog 쿼리
전통적인 Prolog 시스템에서는 쿼리를 시작하면 솔버가 실행되어 찾을 수 있는 모든 가능한 답을 제공하기 시작합니다. 예를 들어 다음과 같은 쿼리가 주어졌다고 합시다:
?- Vec<i32>: AsRef<?U>
솔버는 다음과 같이 답할 수 있습니다:
Vec<i32>: AsRef<[i32]>
continue? (y/n)
이 continue 부분이 흥미롭습니다. Prolog의 아이디어는 솔버가 쿼리를 참으로 만드는 모든 가능한 인스턴스화를 찾는다는 것입니다. 이 경우 ?U = [i32]로 인스턴스화하면 쿼리는 참이 됩니다(전통적인 Prolog 인터페이스는 ?U의 값을 직접 알려 주지 않지만, 응답을 원래 쿼리와 단일화하여 값을 추론할 수 있다는 점에 유의하십시오. Rust의 솔버는 그 대신 치환을 돌려줍니다). y를 누르면 솔버는 또 다른 가능한 답을 줄 수 있습니다:
Vec<i32>: AsRef<Vec<i32>>
continue? (y/n)
이 답은 AsRef에 대한 반사적 impl(impl<T> AsRef<T> for T)이 존재한다는 사실에서 비롯됩니다. y를 다시 누르면 부정적인 응답을 받을 수도 있습니다:
no
물론 어떤 경우에는 가능한 답이 전혀 없을 수도 있으며, 이때 솔버는 곧바로 no를 돌려줍니다:
?- Box<i32>: Copy
no
어떤 경우에는 응답이 무한히 많을 수도 있습니다. 예를 들어 다음 쿼리를 주고 계속 y를 누르면, 솔버는 끝없이 답을 돌려줍니다:
?- Vec<?U>: Clone
Vec<i32>: Clone
continue? (y/n)
Vec<Box<i32>>: Clone
continue? (y/n)
Vec<Box<Box<i32>>>: Clone
continue? (y/n)
Vec<Box<Box<Box<i32>>>>: Clone
continue? (y/n)
짐작할 수 있듯이, 솔버는 우리가 멈추라고 하거나 메모리가 바닥날 때까지 신나게 Box를 한 겹씩 계속 추가합니다.
또 하나 흥미로운 점은 쿼리에 여전히 변수가 남아 있을 수 있다는 것입니다. 예를 들면 다음과 같습니다.
?- Rc<?T>: Clone
다음과 같은 답을 낼 수 있습니다:
Rc<?T>: Clone
continue? (y/n)
결국 Rc<?T>는 ?T가 어떤 타입이든 상관없이 참입니다.
rustc의 트레이트 쿼리
rustc의 트레이트 쿼리는 다소 다르게 동작합니다. 모든 가능한 답을 나열하려 하는 대신, 모호하지 않은 답을 찾습니다. 특히 타입 변수의 값을 알려 준다는 것은, 현재의 impl과 where 절 집합이 주어졌을 때 그것이 증명 가능하면서 사용할 수 있는 유일한 인스턴스화라는 뜻입니다.
rustc에서 트레이트 쿼리에 대한 응답은 일반적으로 Result<QueryResult<T>, NoSolution>입니다(여기서 T는 쿼리 자체에 따라 조금씩 달라집니다). Err(NoSolution) 경우는 쿼리가 거짓이었고 답이 없었음을 나타냅니다(예: Box<i32>: Copy). 그렇지 않은 경우, QueryResult는 우리가 찾아낸 가능한 답(들)에 대한 정보를 돌려줍니다. 이것은 네 부분으로 구성됩니다.
- 확실성(Certainty): 이 답을 얼마나 확신하는지 알려 줍니다. 이것은 두 가지 값을 가질 수 있습니다.
Proven은 결과가 참임이 알려져 있다는 뜻입니다.- 예를 들어
Vec<i32>: Clone이나Rc<?T>: Clone을 증명하려 할 때 이러한 결과가 나올 수 있습니다.
- 예를 들어
Ambiguous는 참인지 또는 거짓인지 아직 증명할 수 없는 것이 있었다는 뜻이며, 일반적으로 더 많은 타입 정보가 필요했기 때문입니다. (곧 예시를 살펴보겠습니다.)Vec<?T>: Clone을 증명하려 할 때 이러한 결과가 나올 수 있습니다.
- 변수 값(Var values): 원래 쿼리에 나타난 바인딩되지 않은 각 추론 변수(
?T등)의 값입니다. (Prolog에서는 이것들을 추론해야 했다는 점을 기억하십시오.)- 아래 예시에서 보겠지만,
Ambiguous인 경우에도 변수 값을 돌려받을 수 있습니다.
- 아래 예시에서 보겠지만,
- 영역 제약(Region constraints): 입력으로 제공한 라이프타임들 사이에 성립해야 하는 관계입니다. 여기서는 이것들을 무시하겠습니다.
- 값(Value): 쿼리 결과에는
T타입의 값도 함께 제공됩니다. 연관 타입 정규화와 같은 일부 특수한 쿼리에서는 이 값이 추가 결과를 전달하는 데 사용되지만, 대개는 그저()입니다.
예시
각 부분이 무엇을 의미하는지 알아보기 위해 예시 쿼리를 살펴봅시다. Borrow 트레이트를 생각해 보십시오. 이 트레이트에는 여러 impl이 있으며, 그중에는 다음 두 가지가 있습니다(명확성을 위해 Sized 바운드를 명시적으로 적었습니다).
impl<T> Borrow<T> for T where T: ?Sized
impl<T> Borrow<[T]> for Vec<T> where T: Sized
예시 1. 다음의 (다소 인위적인) 코드를 타입 검사한다고 생각해 보십시오.
fn foo<A, B>(a: A, vec_b: Option<B>) where A: Borrow<B> { }
fn main() {
let mut t: Vec<_> = vec![]; // Type: Vec<?T>
let mut u: Option<_> = None; // Type: Option<?U>
foo(t, u); // Example 1: requires `Vec<?T>: Borrow<?U>`
...
}
주석에서 알 수 있듯이, 먼저 두 변수 t와 u를 만듭니다. t는 빈 벡터이고 u는 None 옵션입니다. 이 두 변수 모두 타입에 바인딩되지 않은 추론 변수를 가지고 있습니다. ?T는 벡터 t의 요소를 나타내고 ?U는 옵션 u에 저장된 값을 나타냅니다. 다음으로 foo를 호출합니다. foo의 시그니처를 인자와 비교하면 결국 A = Vec<?T>와 B = ?U를 얻게 됩니다. 따라서 foo의 where 절은 Vec<?T>: Borrow<?U>를 요구합니다. 이것이 바로 첫 번째 예시 트레이트 쿼리입니다.
Vec<?T>: Borrow<?U> 쿼리에는 가능한 해가 많이 있습니다. 예를 들면 다음과 같습니다.
?U = Vec<?T>,?U = [?T],?T = u32, ?U = [u32]- 등이 있습니다.
따라서 돌려받는 결과는 다음과 같습니다(영역 제약과 “값“은 무시하겠습니다).
- 확실성:
Ambiguous– 이것이 성립하는지 아직 확실하지 않습니다 - 변수 값:
[?T = ?T, ?U = ?U]– 변수의 값에 대해 아무것도 알아내지 못했습니다
요컨대, 이 쿼리 결과는 이 트레이트가 증명되는지에 대해 무언가를 말하기에는 아직 이르다는 뜻입니다. 타입 검사 중에 이것은 즉시 오류가 되지 않습니다. 대신 타입 검사기는 이 요구 사항(Vec<?T>: Borrow<?U>)을 보류해 두고 기다립니다. 다음 예제에서 보겠지만, ?T와 ?U가 결국 다른 출처로부터 제약을 받게 될 수도 있으며, 그런 경우 트레이트 쿼리를 다시 시도할 수 있습니다.
예제 2. 이제 앞의 예제를 조금 확장하여 u에 값을 대입해 보겠습니다:
fn foo<A, B>(a: A, vec_b: Option<B>) where A: Borrow<B> { }
fn main() {
// What we saw before:
let mut t: Vec<_> = vec![]; // Type: Vec<?T>
let mut u: Option<_> = None; // Type: Option<?U>
foo(t, u); // `Vec<?T>: Borrow<?U>` => ambiguous
// New stuff:
u = Some(vec![]); // ?U = Vec<?V>
}
이 대입의 결과로 u의 타입은 Option<Vec<?V>>로 강제되며, 여기서 ?V는 벡터의 원소 타입을 나타냅니다. 이는 다시 ?U가 Vec<?V>로 단일화된다는 것을 의미합니다.
타입 검사기가 앞서 보았던 “아직 증명되지 않은” 트레이트 의무인 Vec<?T>: Borrow<?U>를 다시 살펴보기로 한다고 가정해 봅시다. ?U는 더 이상 바인딩되지 않은 추론 변수가 아니며, 이제 Vec<?V>라는 값을 가집니다. 따라서 그 값으로 쿼리를 “갱신“하면 다음과 같은 결과를 얻습니다:
Vec<?T>: Borrow<Vec<?V>>
이번에는 적용되는 impl이 반사적(reflexive) impl 하나뿐입니다:
impl<T> Borrow<T> for T where T: ?Sized
따라서 트레이트 검사기는 다음과 같이 답할 것입니다:
- 확실성:
Proven - 변수 값:
[?T = ?T, ?V = ?T]
여기서 이 결과는 의무가 성립함을 실제로 증명했으며, ?T와 ?V가 같은 타입이라는 것도 알게 되었다는 뜻입니다(다만 그 타입이 무엇인지는 아직 모릅니다!).
(실제로는 함수가 여기서 끝나므로, 타입 검사기는 이 시점에서 오류를 낼 것입니다. t와 u의 원소 타입이 서로 같다는 것은 알려져 있지만, 그 타입 자체는 여전히 알려지지 않았기 때문입니다.)
표준화
NOTE: FIXME: 이 장의 내용은 차세대 트레이트 해결 표준화 장과 일부 겹칩니다. 향후 이 내용을 재구성할 것을 권장합니다.
표준화는 추론 값을 그 컨텍스트로부터 격리하는 과정입니다. 이는 정준 쿼리를 구현하는 데 핵심적인 부분이며, 더 많은 맥락을 얻으려면 상위 장을 읽어 보셔도 좋습니다.
표준화는 사실 매우 단순한 개념에 기반합니다. 모든 추론 변수는 항상 두 상태 중 하나에 있습니다. 바인딩되지 않은 상태로서 아직 어떤 타입인지 알지 못하는 경우이거나, 바인딩된 상태로서 어떤 타입인지 아는 경우입니다. 따라서 타입/리전을 포함하는 어떤 데이터 구조 T를 그 환경으로부터 격리하려면, T를 따라 내려가며 T에 나타나는 바인딩되지 않은 변수들을 찾기만 하면 됩니다. 그 변수들은 “정준 변수“로 대체되며, 0부터 시작하여 고정된 순서로 번호가 매겨집니다(대체로 왼쪽에서 오른쪽 순서이지만, 일관되기만 하면 실제로는 순서가 중요하지 않습니다).
예를 들어 X = (?T, ?U)라는 타입이 있고 ?T와 ?U가 서로 다른, 바인딩되지 않은 추론 변수라면, X의 정준 형태는 (?0, ?1)이 되며, 여기서 ?0과 ?1은 이러한 정준 자리 표시자를 나타냅니다. Y = (?U, ?T) 타입 역시 (?0, ?1)로 정준화된다는 점에 유의하십시오. 그러나 Z = (?T, ?T) 타입은 (?0, ?0)으로 정준화됩니다((?U, ?U)도 마찬가지입니다). 다시 말해, 추론 변수가 정확히 무엇인지는 중요하지 않습니다. 단, 반복되는 경우는 예외입니다.
우리는 이를 사용하여 캐싱을 개선할 뿐만 아니라 트레이트 해석 중에 순환 및 기타 사항을 감지합니다. 대략적으로 말하면, 두 트레이트 쿼리의 정준 형태가 같다면 같은 답을 얻게 된다는 것이 핵심 아이디어입니다. 그 답은 정준 변수(?0, ?1)로 표현되며, 이를 다시 원래 변수(?T, ?U)로 대응시킬 수 있습니다.
쿼리 정준화하기
작동 방식을 이해하기 위해, 다음 트레이트 쿼리의 해결을 요청한다고 가정해 봅시다. 쿼리는 ?A: Foo<'static, ?B>이며, 여기서 ?A와 ?B는 바인딩되지 않은 상태입니다. 이 쿼리는 바인딩되지 않은 변수 두 개를 포함하지만, 라이프타임 'static도 포함합니다. 트레이트 시스템은 일반적으로 모든 라이프타임을 무시하고 동일하게 취급하므로, 정준화할 때 모든 자유 라이프타임도 정준 변수로 대체합니다(여기서 'static은 실제로 자유 라이프타임 변수라는 점에 유의하십시오. 우리는 이를 전체 프로그램의 타이핑 컨텍스트에서 고려하는 것이 아니라 이 트레이트 참조의 컨텍스트에서만 고려합니다. 수학적으로 말하면, 프로그램 전체에 대해 한정하는 것이 아니라 이 의무에 대해서만 한정합니다). 따라서 다음과 같은 결과를 얻습니다:
?0: Foo<'?1, ?2>
때로는 이를 다음과 같이 다르게 표기하기도 합니다:
for<T,L,T> { ?0: Foo<'?1, ?2> }
이 for<>는 그 안에 있는 각 정준 변수에 대한 정보를 제공합니다. 이 경우 각 T는 타입 변수를 나타내므로 ?0과 ?2는 타입이고, L은 라이프타임 변수를 나타내므로 ?1은 라이프타임입니다. canonicalize 메서드는 정준화된 각 변수의 “원래 값“을 담은 CanonicalVarValues 배열 OV도 돌려줍니다:
[?A, 'static, ?B]
이 벡터 OV는 나중에 쿼리 응답을 처리할 때 필요합니다.
쿼리 실행하기
정준 쿼리를 구성하고 나면 이를 해결하려고 시도할 수 있습니다. 이를 위해 우리는 결국 새로운 추론 컨텍스트를 생성하고 그 컨텍스트에서 정준 쿼리를 인스턴스화하게 됩니다. 핵심 아이디어는 각 정준 변수마다 (적절한 종류의) 새로운 추론 변수를 담은 치환 S를 정준 형태로부터 만드는 것입니다. 따라서 앞의 예제 쿼리가 다음과 같다면:
for<T,L,T> { ?0: Foo<'?1, ?2> }
치환 S는 다음과 같을 수 있습니다:
S = [?A, '?B, ?C]
그런 다음 바운드 정준 변수(?0 등)를 이 추론 변수들로 치환하여 다음과 같이 완전히 인스턴스화된 쿼리를 얻을 수 있습니다.
?A: Foo<'?B, ?C>
하지만 그 치환 S를 꼭 기억해 두십시오! 나중에 필요하게 될 것입니다.
이제 새로운 추론 컨텍스트와 인스턴스화된 쿼리가 준비되었으므로, 이를 풀어 볼 수 있습니다. 트레이트 솔버 자체는 다른 절에서 더 자세히 설명하지만, 여기서는 트레이트 솔버가 확실성 값(Proven 또는 Ambiguous)을 계산하고 우리가 생성한 추론 변수에 부수 효과를 일으킨다는 정도만 말해 두면 충분합니다. 예를 들어, 다음과 같이 Foo의 impl이 하나뿐이라면:
impl<'a, X> Foo<'a, X> for Vec<X>
where X: 'a
{ ... }
확실성 값으로 Proven을 얻게 될 수 있으며, 동시에 (impl의 매개변수를 나타내기 위해) 새로운 추론 변수 '?D와 ?E를 생성하고 다음과 같이 단일화하게 됩니다.
'?B = '?D?A = Vec<?E>?C = ?E
where 절로 인해 영역 제약 ?E: '?D도 누적하게 됩니다.
최종 쿼리 결과를 만들려면, 이 값들을 쿼리의 추론 컨텍스트에서 “끌어올려” 원래의 추론 컨텍스트에 다시 적용할 수 있는 형태로 바꾸어야 합니다. 우리는 표준화를 다시 적용하여 이를 수행하지만, 이번에는 쿼리 결과에 적용합니다.
쿼리 결과 정규화하기
상위 절에서 논의한 바와 같이, 대부분의 트레이트 쿼리는 결국 “확실성 값” certainty, 결과 치환 var_values, 그리고 몇몇 영역 제약을 한데 묶은 결과를 내게 됩니다. 이를 만들기 위해, 처음 쿼리를 인스턴스화할 때 생성한 치환 S를 재사용하게 됩니다. 기억(메모리)을 되살려 보자면, 우리에게는 다음과 같은 쿼리가 있었습니다
for<T,L,T> { ?0: Foo<'?1, ?2> }
이에 대해 다음과 같은 치환 S를 만들었습니다.
S = [?A, '?B, ?C]
그런 다음 그 변수들 중 일부를 다른 것들과 단일화하는 작업을 수행했습니다. 최신 결과로 S를 “갱신“하면 다음을 얻습니다.
S = [Vec<?E>, '?D, ?E]
이것들은 정확히 원래 쿼리의 세 입력 변수에 대한 새로운 값입니다. 하지만 여기에는 (?E와 같은) 새로운 변수가 일부 포함되어 있다는 점에 유의하십시오. 다시 정규화하면 이것들을 없앨 수 있습니다! 다만 S만 정규화하는 것이 아니라, 쿼리 응답 QR 전체를 정규화합니다.
QR = {
certainty: Proven, // or whatever
var_values: [Vec<?E>, '?D, ?E] // this is S
region_constraints: [?E: '?D], // from the impl
value: (), // for our purposes, just (), but
// in some cases this might have
// a type or other info
}
결과는 다음과 같습니다.
Canonical(QR) = for<T, L> {
certainty: Proven,
var_values: [Vec<?0>, '?1, ?0]
region_constraints: [?0: '?1],
value: (),
}
(한 가지 미묘한 점이 있습니다. 쿼리 결과를 정규화할 때는 자유 라이프타임을 특별히 취급하지 않습니다. 예를 들어, '?D에 대한 두 참조가 모두 같은 정준 변수(?1)로 변환되었다는 점에 유의하십시오. 이는 모든 자유 라이프타임을 새로운 정준 변수로 표준화했던 원래 쿼리와 대조됩니다.)
이제 이 결과를 필요한 각 컨텍스트에 다시 적용해야 합니다.
정규화된 쿼리 결과 처리하기
이전 절에서 우리는 정준 쿼리 결과를 만들었습니다. 이제 그 결과를 원래의 컨텍스트에 적용해야 합니다. 기억하시겠지만, 맨 처음에 우리는 다음 쿼리를 증명하려 하고 있었습니다:
?A: Foo<'static, ?B>
우리는 이것을 다음과 같이 정규화했습니다:
for<T,L,T> { ?0: Foo<'?1, ?2> }
그리고 이제 다음과 같은 정규 응답을 돌려받았습니다:
for<T, L> {
certainty: Proven,
var_values: [Vec<?0>, '?1, ?0]
region_constraints: [?0: '?1],
value: (),
}
이제 그 응답을 우리의 컨텍스트에 적용하려 합니다. 개념적으로 이를 수행하는 방법은 (a) 결과에 있는 각 정준 변수를 새로운 추론 변수로 인스턴스화하고, (b) 결과의 값들을 원래 값들과 단일화한 다음, (c) 나중을 위해 영역 제약을 기록하는 것입니다. (a) 단계를 수행하면 다음과 같은 결과가 나옵니다
{
certainty: Proven,
var_values: [Vec<?C>, '?D, ?C]
^^ ^^^ fresh inference variables
region_constraints: [?C: '?D],
value: (),
}
그다음 (b) 단계에서는 다음을 단일화합니다:
?A with Vec<?C>
'static with '?D
?B with ?C
그리고 마지막으로 ?C: 'static이라는 영역 제약 조건이 나중에 검증할 수 있도록 기록됩니다.
(우리가 실제로 수행하는 것은 이를 약간 최적화한 변형입니다. 결과에 있는 모든 정준 값을 변수로 미리 인스턴스화하는 대신, 값의 벡터를 순회하면서 값이 단지 정준 변수에 불과한 경우를 찾습니다. 우리 예제에서 values[2]는 ?C이므로, ?C := ?B이고 '?D := 'static이라고 추론할 수 있습니다. 이를 통해 값들 중 일부를 얻습니다. 값을 찾지 못한 것에 대해서는 추론 변수를 생성합니다.)
트레이트 해결(신규)
이 장에서는 rustc_trait_selection/solve에 있는, 현재 개발 중인(WIP) 새 솔버에서 트레이트 해결이 어떻게 동작하는지 설명합니다. 현재 솔버와 chalk 솔버에 관한 문서도 자유롭게 참고하십시오.
핵심 개념
트레이트 시스템의 목표는 주어진 트레이트 바운드가 충족되는지 확인하는 것입니다. 이는 특히 제네릭일 수도 있는 함수의 본문을 타입 검사할 때 중요합니다. 예를 들면 다음과 같습니다.
#![allow(unused)]
fn main() {
fn uses_vec_clone<T: Clone>(x: Vec<T>) -> (Vec<T>, Vec<T>) {
(x.clone(), x)
}
}
여기서 x.clone()을 호출하려면 T: Clone이 참이라는 가정하에 Vec<T>가 Clone을 구현한다는 것을 증명해야 합니다. T: Clone은 이 함수의 호출자가 증명할 것이므로 이를 가정할 수 있습니다.
“T: Clone이라는 가정하에 Vec<T>: Clone을 증명하라“라는 개념을 Goal이라고 합니다. Vec<T>: Clone과 T: Clone은 모두 Predicate를 사용하여 표현됩니다. 다른 술어도 있으며, 그중 가장 대표적인 것은 연관 항목에 대한 동등성 바운드인 <Vec<T> as IntoIterator>::Item == T입니다. 전체 목록은 PredicateKind 열거형을 참고하십시오. Goal은 증명해야 하는 predicate와 이 술어가 성립해야 하는 param_env로 표현됩니다.
목표를 증명할 때는 가능한 각 Candidate가 주어진 목표에 적용되는지를, 그 후보의 중첩된 목표를 재귀적으로 증명하는 방식으로 확인합니다. 가능한 후보 목록과 예시는 CandidateSource를 참고하십시오. 가장 중요한 후보는 사용자가 작성한 트레이트 구현인 Impl 후보와, 현재 환경의 가정인 ParamEnv 후보입니다.
위의 예시에서 Vec<T>: Clone을 증명하려면 먼저 impl<T: Clone> Clone for Vec<T>를 사용합니다. 이 impl을 사용하려면 T: Clone이 성립한다는 중첩된 목표를 증명해야 합니다. 이 목표는 중첩된 목표가 없는 ParamEnv의 가정 T: Clone을 사용해 증명할 수 있습니다. 따라서 Vec<T>: Clone이 성립합니다.
트레이트 솔버는 성공, 모호성, 오류 중 하나를 CanonicalResponse로 반환합니다. 성공과 모호성의 경우에는 추론 제약과 영역 제약도 함께 반환합니다.
타입 시스템의 불변 조건
FIXME: 이 파일은 솔버만이 아니라 타입 시스템 전체의 불변 조건을 다룹니다
다른 언어와 타입 시스템에서는 불변 조건, 즉 타입 시스템이 항상 참임을 보장하는 사항이 많이 있으며 이러한 조건은 바람직하게 여겨지거나 당연히 기대됩니다. 안타깝게도 그중 상당수는 현재 Rust에서 성립하지 않습니다. 이는 설계상 근본적인 이유 때문이거나 버그 때문이며, 앞으로 바뀔 수도 있습니다.
타입 시스템 자체를 작업하거나 타입 시스템을 활용하여 작업할 때 무엇을 가정할 수 있는지 아는 것이 중요합니다. 그래서 핵심 타입 시스템의 불변 조건을 정리한 불완전하고 비공식적인 목록을 아래에 제시합니다:
- ✅: 이 불변 조건은 몇 가지 이상한 예외나 현재 존재하는 버그를 제외하면 대체로 성립합니다
- ❌: 이 불변 조건은 성립하지 않으며, 앞으로도 성립할 가능성이 낮습니다. 건전성을 위해 이에 의존하지 마십시오. 의존해야 한다면 극도로 주의하십시오
wf(X)는 wf(normalize(X))를 함의합니다 ✅
별칭을 포함하는 타입이 적격이라면, 해당 별칭을 정규화한 후에도 적격이어야 합니다. 그렇지 않으면 이러한 타입에 대해 적격성을 다시 검사해야 하므로 우리는 이 성질에 의존합니다.
이는 현재 타입 시스템의 불건전성 문제 때문에 성립하지 않습니다: #84533.
리전을 무시한 구조적 동등성은 의미적 동등성을 함의합니다 ✅
어떤 타입이 있을 때 lhs와 rhs 양쪽에서 모든 리전을 고유한 추론 변수로 치환한 뒤 그 타입을 자기 자신과 동등하게 만들면, 이제 구조적으로 달라졌을 수 있는 두 타입은 여전히 서로 동등해야 합니다.
이는 목표가 HIR typeck에서는 성공했다가 MIR borrowck에서 실패하는 것을 방지하기 위해 필요합니다. 이 불변 조건이 깨지면 MIR 타입 검사가 결국 ICE와 함께 실패합니다.
목표에서 얻은 추론 결과를 적용해도 그 목표의 결과는 바뀌지 않습니다 ❌
TODO: 이 불변 조건은 이상한 방식으로 서술되어 있어 더 자세히 설명해야 합니다. 요컨대, 이 검사는 솔버 버그가 있을 때에만 실패하기를 바랍니다: https://github.com/rust-lang/rust/blob/2ffeb4636b4ae376f716dc4378a7efb37632dc2d/compiler/rustc_trait_selection/src/solve/eval_ctxt.rs#L391-L407. 이 검사를 다시 추가하고 어디에서 깨지는지 확인해 봅시다 :3
어떤 목표를 증명하거나 타입을 동등하게 만드는 등의 작업을 수행하고, 그 결과로 나온 추론 제약을 적용한 다음 원래 작업을 다시 수행하면 결과가 같아야 합니다.
안타깝게도 이는 몇 가지 골치 아픈 이유로 인해 적어도 새 솔버에서는 성립하지 않습니다.
트레이트 솔버는 지역적으로 건전해야 합니다 ✅
이는 impl이 존재하지 않는 목표에 대해 절대로 성공을 반환해서는 안 된다는 뜻입니다. 그렇게 하면 구현되지 않은 트레이트를 구현되었다고 가정하게 되며, 이는 실제 불건전성으로 이어질 가능성이 매우 높습니다. where 바운드를 사용하여 목표를 증명할 때에는 해당 항목의 사용자가 impl을 제공합니다.
이 불변식은 영역 제약을 검사하는 경우에만 성립합니다. coherence의 암묵적 부정 중첩 검사 중에는 영역 제약을 검사하지 않으므로, 그곳에서는 이 불변식이 깨집니다. 이 검사는 트레이트 솔버의 완전성에 의존하므로, 타입 outlives 목표에 대한 처리가 불완전한 현재의 영역 제약 검사인 InferCtxt::resolve_regions를 사용할 수 없습니다.
빈 환경에서 의미적으로 동등한 별칭들을 정규화하면 하나의 고유한 타입이 나옵니다 ✅
별칭 타입/상수의 정규화는 유일한 결과를 가져야 합니다. 그렇지 않으면 안전한 코드에서 transmute를 쉽게 구현할 수 있습니다. 다음 함수가 주어졌을 때, 입력 타입과 출력 타입이 항상 같은 구체 타입으로 정규화되도록 보장해야 합니다.
#![allow(unused)]
fn main() {
fn foo<T: Trait>(
x: <T as Trait>::Assoc
) -> <T as Trait>::Assoc {
x
}
}
현재 알려진 건전성 문제 중 상당수는 결국 이 불변식이 깨진다는 점에 기대고 있습니다. 그러나 이 불변식 없이 건전한 타입 시스템을 상상하기는 매우 어려우므로, 문제는 우리가 이 불변식에 잘못 의존하는 것이 아니라 불변식이 깨져 있다는 점입니다.
타입 시스템은 완전합니다 ❌
타입 시스템은 완전하지 않습니다. 타입 시스템은 불필요한 추론 제약을 추가하는 경우가 많으며, 목표(goal)가 성립할 수 있는데도 오류를 냅니다.
- 메서드 선택
- 불투명 타입 추론
- 타입 outlives 제약 처리
- 트레이트 솔버의 후보 선택 과정에서
Impl후보보다ParamEnv후보를 우선하는 것
목표는 HIR 타입 검사(typeck) 이후에도 그 결과를 유지합니다 ✅
HIR typeck 중에는 성공하지만 MIR borrowck 중에 다시 평가될 때 실패하는 목표가 있으면 ICE가 발생합니다. 예를 들어 #140211이 있습니다.
HIR 타입 검사 중에는 성공하지만 인스턴스화된 후에 실패하는 목표가 있으면 건전하지 않습니다. 예: #140212.
이 제약을 유지하면서도 트레이트 솔버에서 어느 정도의 불완전성을 허용한다는 점은 흥미롭습니다. “허용된 불완전성“을 이 불변식을 깨뜨리는 동작과 명확하게 구분할 방법이 있다면 좋을 것입니다.
정규화는 결과를 바꾸어서는 안 됩니다
제네릭 별칭의 정규화를 허용하기 위해 이 불변식에 의존합니다. 이를 깨뜨리면 쉽게 건전성 문제로 이어질 수 있습니다. 예: #57893.
목표는 인스턴스화 후에도 여전히 오버플로할 수 있습니다
이는 재귀 한도에 도달하기 시작할 때 발생합니다. 또한 엉망인 발산하는 별칭도 있습니다. 이들을 어떻게 처리해야 할지는 불분명합니다 :3
빈 환경에서의 트레이트 목표는 유일한 impl에 의해 증명됩니다 ✅
트레이트 목표가 빈 환경에서 성립한다면, 그 목표를 증명하는 데 사용되는 impl이 사용자 정의든 내장이든 유일하게 존재해야 합니다. 이는 유일한 메서드와 연관 항목을 선택하는 데 필요합니다.
그러나 몇몇 경우에는 이 불변식을 깨뜨리는데, 일부는 버그 때문이고 일부는 의도된 설계입니다:
- 마커 트레이트는 연관 항목이 없으므로 겹치는 것이 허용됩니다
- 특수화는 특수화하는 impl이 부모 impl과 겹치는 것을 허용합니다
- 내장 트레이트 객체 트레이트 구현은 사용자 정의 impl과 겹칠 수 있습니다: #57893
비어 있지 않은 환경에서 증명될 수 있는 목표는 단형화 중에도 성립합니다 ✅
제네릭 환경에서 어떤 목표를 증명할 수 있다면, 그 목표를 완전히 구체적인 타입으로 인스턴스화하고 스코프에 where 절이 없는 상태에서도 그 목표는 여전히 성립해야 합니다.
코드 생성은 이를 전제로 하며, 오버플로가 아닌 모호성을 만나면 ICE를 일으킵니다. 이 불변식은 현재 특수화(#147507)와 마커 트레이트(#149502)로 인해 깨져 있습니다.
일관성(coherence) 검사의 암묵적 부정 중첩 검사 중에는 타입 시스템이 완전합니다 ✅
중첩 검사에 대한 자세한 내용은 일관성 장을 참고하십시오.
일관성 검사의 암묵적 부정 중첩 검사 중에는 증명할 수 있는 목표에 대해 절대로 오류를 반환해서는 안 됩니다. 그렇게 되면 서로 다른 연관 항목을 가질 수 있는 impl들이 중첩될 수 있게 되어, 다른 여러 불변식이 깨집니다.
이 불변식은 실제로 우리가 의존하는 것임에도 현재 여러 가지 방식으로 깨져 있습니다. 이 불변식은 깨지기 매우 쉬우므로 주의해야 합니다:
- 별칭의 일반화
- 서브타이핑 바인더 중의 일반화 (다행히 일관성 검사에서는 악용할 수 없습니다)
트레이트 해결은 라이프타임이 서로 다르다는 사실에 의존해서는 안 됩니다 ✅
라이프타임이 서로 다를 때 어떤 목표가 성립한다면, 그 라이프타임들이 같을 때에도 성립해야 합니다. 그렇지 않으면 코드 생성 중에 단형화 이후 오류가 발생하거나, 잘못된 vtable로 인해 건전성이 깨집니다.
또한 서로 다른 라이프타임으로 목표를 먼저 증명한 뒤 나중에 그 라이프타임들이 같도록 제약되면 일관되지 않은 동작이 나타날 수도 있습니다.
본문에서의 트레이트 해결은 라이프타임이 같다는 사실에 의존해서는 안 됩니다 ✅
또한 트레이트 솔버에서 영역의 동등성에 의존하는 것에도 주의해야 합니다. 코드 생성에서는 지워진 영역을 모두 같은 것으로 취급하므로 이는 문제가 되지 않습니다. 하지만 HIR 타입 검사에서 MIR 타입 검사로 넘어가는 과정에서 동등성 정보를 잃을 수 있습니다.
이는 현재 새 솔버에서는 성립하지 않습니다: trait-system-refactor-initiative#27.
모호성을 제거하면 엄밀히 더 많은 코드가 컴파일됩니다 ❌
이상적으로는 코드가 컴파일되기 위해 모호성에 의존해서는 안 됩니다. 그렇게 하지 않으면 향후의 개선이 호환성을 깨는 변경이 됩니다.
불완전성 때문에 실제로는 그렇지 않으며, 추론을 개선하면 추론 결과가 바뀌어 기존 프로젝트가 깨질 수 있습니다.
의미적 동등성은 구조적 동등성을 함의합니다 ✅
타입 시스템에서 두 타입이 같다는 것은 제네릭 매개변수를 구체적인 인자로 인스턴스화한 뒤 두 타입이 같은 TypeId를 가진다는 뜻이어야 합니다. 그렇지 않으면 서로 다른 TypeId를 이용해 트레이트 선택에 영향을 줄 수 있습니다.
코드 생성 중에는 구조적 동등성을 사용해 타입을 조회하지만, 이것이 반드시 건전성을 깨는 것은 아닙니다
- 중복된 메서드 코드 생성이나 백엔드 타입 검사 오류를 일으킬 수 있습니까?
- CTFE 단언에서도 이에 의존합니다
의미적으로 다른 타입은 서로 다른 TypeId를 가집니다 ✅
의미적으로 다른 'static 타입은 transmute를 막기 위해 서로 다른 TypeId를 가져야 합니다. 예를 들어 for<'a> fn(&'a str)와 fn(&'static str)는 서로 다른 TypeId를 가져야 합니다.
상수 항목의 평가는 결정적입니다 ✅
상수 항목의 값은 타입 시스템에 반영될 수 있으므로, 상수 항목의 값이 모든 크레이트에서 항상 동일한 것이 중요합니다. 그렇지 않다면 “동일한” 상수 인자를 가져서 “동일한” 연관 타입이 되는 연관 타입이 생길 수 있지만, 서로 다른 크레이트에서 코드 생성 중에 정규화하면 실제로는 서로 다른 타입이 될 수 있습니다.
특히 이는 상수 함수로는 확장되지 않습니다. 타입 시스템은 상수 항목의 결과만을 다루므로, 상수 항목의 최종 값에 영향을 주지 않는 한 상수 함수가 비결정적이어도 실제로는 문제가 없습니다.
솔버
chalk의 재귀 솔버에 대한 문서도 읽어 보십시오. 이 구현과 매우 유사하며, 이 접근 방식의 한계도 다룹니다.
대략적인 흐름
솔버의 진입점은 InferCtxtEvalExt::evaluate_root_goal입니다. 이 함수는 루트 EvalCtxt를 설정한 다음, 실제로 트레이트 솔버에 진입하기 위해 EvalCtxt::evaluate_goal을 호출합니다.
EvalCtxt::evaluate_goal은 표준화, 캐싱, 오버플로, 솔버 사이클을 처리합니다. 이 작업이 끝나면 별도의 로컬 InferCtxt를 가진 중첩된 EvalCtxt를 생성하고, ’실제 솔버 동작’을 담당하는 EvalCtxt::compute_goal을 호출합니다. PredicateKind에 대해 매칭하여 각 종류를 별도의 함수에 위임합니다.
Vec<T>: Clone과 같은 트레이트 목표의 경우, EvalCtxt::compute_trait_goal은 EvalCtxt::assemble_and_evaluate_candidates를 통해 이 목표를 증명할 수 있는 모든 방법을 수집해야 합니다. 추론 제약이 다른 후보로 새어 나가지 않도록 각 후보는 별도의 “프로브(probe)“에서 처리됩니다. 그런 다음 EvalCtxt::merge_candidates를 통해 수집된 후보들을 병합하려고 시도합니다.
중요한 개념과 설계 패턴
EvalCtxt::add_goal
중첩된 목표를 증명할 때는 EvalCtxt::compute_goal을 직접 호출하지 않고, 대신 EvalCtxt::add_goal로 목표를 EvalCtxt에 추가합니다. 그런 다음 EvalCtxt::try_evaluate_added_goals 또는 EvalCtxt::evaluate_added_goals_and_make_canonical_response에서 모든 중첩된 목표를 함께 증명합니다. 이를 통해 나중 목표에서 비롯된 추론 제약을 처리할 수 있습니다.
예를 들어 ?x: Debug와 (): ConstrainToU8<?x>가 모두 중첩된 목표로 있다면, ?x: Debug의 증명은 처음에는 모호하지만, (): ConstrainToU8<?x>를 증명한 뒤에는 ?x를 u8로 제약하게 되어 u8: Debug의 증명이 성공합니다.
TyKind에 대한 매칭
솔버에서는 타입을 지연 정규화하므로, 어떤 타입과 상수든 정규화되지 않았을 가능성이 있다고 항상 가정해야 합니다. 이는 TyKind에 대한 매칭이 쉽게 잘못될 수 있음을 의미합니다.
정규화는 두 가지 방식으로 처리합니다. Trait 목표를 증명하거나 연관 타입을 정규화할 때는, 후보가 self 타입과 구조적으로 일치하는지에 따라 후보를 별도로 수집합니다. self 타입에 대해 매칭하는 후보는 EvalCtxt::assemble_candidates_via_self_ty에서 처리되며, 이 함수는 self 타입을 한 단계 정규화하는 EvalCtxt::assemble_candidates_after_normalizing_self_ty를 통해 재귀합니다. TyKind에 대해 매칭해야 하는 그 밖의 모든 경우에는 먼저 EvalCtxt::try_normalize_ty를 사용하여 타입을 최대한 정규화합니다.
고차 목표
목표가 for<'a> F: FnOnce(&'a ())처럼 고차(higher-ranked)인 경우, EvalCtxt::compute_goal은 'a를 플레이스홀더로 즉시 인스턴스화한 다음 F: FnOnce(&'!a ())를 중첩된 목표로 재귀적으로 증명합니다.
선택 처리
어떤 목표는 여러 방법으로 증명될 수 있습니다. 이러한 경우 각 선택지를 별도의 “프로브“에서 시도한 다음, EvalCtxt::try_merge_responses를 사용하여 결과 응답들을 병합하려고 시도합니다. 응답 병합에 실패하면 대신 EvalCtxt::flounder를 사용하여 모호성을 반환합니다. 일부 목표에서는 EvalCtxt::try_merge_responses가 실패할 경우, 불완전하게나마 일부 선택지를 다른 선택지보다 우선하려고 시도합니다.
더 알아보기
솔버는 비교적 독립적으로 구성되어 있을 것입니다. 위의 정보가 코드 자체를 살펴볼 때 좋은 토대가 되기를 바랍니다. 그 과정에서 막히거나, 불분명해서 더 나은 주석이 필요하거나 여기에서 언급되어야 할 특이점이나 설계 결정이 있다면 Zulip에서 연락해 주십시오.
후보 선호
Trait 목표와 NormalizesTo 목표를 증명하는 방법은 여러 가지가 있습니다. 이러한 각각의 선택지를 Candidate라고 부릅니다. 적용 가능한 후보가 여러 개인 경우, 일부 후보를 다른 후보보다 선호합니다. 관련 정보는 각 후보의 CandidateSource에 저장합니다.
이러한 선호는 잘못된 추론이나 영역 제약을 초래할 수 있으므로, 일관성(coherence) 검사 중에는 건전하지 않습니다. 이 때문에 일관성 검사에서는 단순히 모든 후보를 병합하려고 시도합니다.
Trait 목표
트레이트 목표는 적용 가능한 후보들을 fn merge_trait_candidates에서 병합합니다. 이 문서는 현재의 선호 규칙이 왜 이렇게 정해졌는지 설명하기 위한 추가 세부 사항과 참고 자료를 제공합니다.
CandidateSource::BuiltinImpl(BuiltinImplSource::Trivial))
자명한 내장 impl은 적격 타입에 대해 항상 적용 가능하다고 알려진 내장 impl입니다. 즉, 이러한 impl이 존재한다면 다른 후보를 사용해도 제약이 더 적어지는 일은 결코 없어야 합니다. 현재는 Sized impl과 MetaSized impl만 자명한 것으로 간주합니다.
이는 다음 패턴에서 라이프타임 오류를 방지하기 위해 필요합니다.
#![allow(unused)]
fn main() {
trait Trait<T>: Sized {}
impl<'a> Trait<u32> for &'a str {}
impl<'a> Trait<i32> for &'a str {}
fn is_sized<T: Sized>(_: T) {}
fn foo<'a, 'b, T>(x: &'b str)
where
&'a str: Trait<T>,
{
// Elaborating the `&'a str: Trait<T>` where-bound results in a
// `&'a str: Sized` where-bound. We do not want to prefer this
// over the builtin impl.
is_sized(x);
}
}
내장 impl에 매개변수가 아닌 where 절에 의존하는 중첩된 목표가 있는 경우, 이 우선순위는 올바르지 않습니다
#![allow(unused)]
fn main() {
struct MyType<'a, T: ?Sized>(&'a (), T);
fn is_sized<T>() {}
fn foo<'a, T: ?Sized>()
where
(MyType<'a, T>,): Sized,
MyType<'static, T>: Sized,
{
// The where-bound is trivial while the builtin `Sized` impl for tuples
// requires proving `MyType<'a, T>: Sized` which can only be proven by
// using the where-clause, adding an unnecessary `'static` constraint.
is_sized::<(MyType<'a, T>,)>();
//~^ ERROR lifetime may not live long enough
}
}
CandidateSource::ParamEnv
전역이 아닌 ParamEnv 후보가 하나라도 있으면, 다른 종류의 후보보다 모든 ParamEnv 후보를 선호합니다. where 바운드가 고차(higher-ranked)가 아니고 제네릭 매개변수를 전혀 포함하지 않으면 전역 where 바운드입니다. 전역 where 바운드는 'static을 포함할 수 있습니다.
사용자는 대개 where 바운드를 가장 잘 제어할 수 있어서 후보 선호가 잘못된 경우에도 가장 쉽게 조정할 수 있으므로, 다른 후보보다 where 바운드를 우선 적용하려고 합니다.
Impl 후보보다 우선하는 선호
이는 다음 예제에서 영역 오류를 피하기 위해 필요합니다.
#![allow(unused)]
fn main() {
trait Trait<'a> {}
impl<T> Trait<'static> for T {}
fn impls_trait<'a, T: Trait<'a>>() {}
fn foo<'a, T: Trait<'a>>() {
impls_trait::<'a, T>();
}
}
가려진(shadowed) impl 때문에 현재 모호한 솔버 순환이 생길 수 있으므로 이 선호가 필요하기도 합니다: trait-system-refactor-initiative#76. 이 선호가 없다면, where 바운드가 영역 제약을 만들어 낼 때 불완전성을 피하기 위해 모호성 오류로 실패할 수밖에 없습니다.
#![allow(unused)]
fn main() {
trait Super {
type SuperAssoc;
}
trait Trait: Super<SuperAssoc = Self::TraitAssoc> {
type TraitAssoc;
}
impl<T, U> Trait for T
where
T: Super<SuperAssoc = U>,
{
type TraitAssoc = U;
}
fn overflow<T: Trait>() {
// We can use the elaborated `Super<SuperAssoc = Self::TraitAssoc>` where-bound
// to prove the where-bound of the `T: Trait` implementation. This currently results in
// overflow.
let x: <T as Trait>::TraitAssoc;
}
}
이 선호는 많은 문제를 일으킵니다. #24066을 참고하십시오. 문제 대부분은 where 바운드가 타입 추론을 이끄는 경우에도 impl보다 where 바운드를 선호하기 때문에 발생합니다:
#![allow(unused)]
fn main() {
trait Trait<T> {
fn call_me(&self, x: T) {}
}
impl<T> Trait<u32> for T {}
impl<T> Trait<i32> for T {}
fn bug<T: Trait<U>, U>(x: T) {
x.call_me(1u32);
//~^ ERROR mismatched types
}
}
하지만 where 바운드가 추론을 이끌지 않는 경우에만 이 선호를 적용하더라도, 여전히 잘못된 라이프타임 제약이 생길 수 있습니다:
#![allow(unused)]
fn main() {
trait Trait<'a> {}
impl<'a> Trait<'a> for &'a str {}
fn impls_trait<'a, T: Trait<'a>>(_: T) {}
fn foo<'a, 'b>(x: &'b str)
where
&'a str: Trait<'b>
{
// Need to prove `&'x str: Trait<'b>` with `'b: 'x`.
impls_trait::<'b, _>(x);
//~^ ERROR lifetime may not live long enough
}
}
AliasBound 후보보다 우선하는 선호
이는 다음 예제에서 영역 오류를 피하기 위해 필요합니다.
#![allow(unused)]
fn main() {
trait Bound<'a> {}
trait Trait<'a> {
type Assoc: Bound<'a>;
}
fn impls_bound<'b, T: Bound<'b>>() {}
fn foo<'a, 'b, 'c, T>()
where
T: Trait<'a>,
for<'hr> T::Assoc: Bound<'hr>,
{
impls_bound::<'b, T::Assoc>();
impls_bound::<'c, T::Assoc>();
}
}
이 선호는 불필요한 제약을 초래할 수도 있습니다.
#![allow(unused)]
fn main() {
trait Bound<'a> {}
trait Trait<'a> {
type Assoc: Bound<'a>;
}
fn impls_bound<'b, T: Bound<'b>>() {}
fn foo<'a, 'b, T>()
where
T: for<'hr> Trait<'hr>,
<T as Trait<'b>>::Assoc: Bound<'a>,
{
// Using the where-bound for `<T as Trait<'a>>::Assoc: Bound<'a>`
// unnecessarily equates `<T as Trait<'a>>::Assoc` with the
// `<T as Trait<'b>>::Assoc` from the env.
impls_bound::<'a, <T as Trait<'a>>::Assoc>();
// For a `<T as Trait<'b>>::Assoc: Bound<'b>` the self type of the
// where-bound matches, but the arguments of the trait bound don't.
impls_bound::<'b, <T as Trait<'b>>::Assoc>();
}
}
전역 where 바운드를 우선하지 않는 이유
전역 where 바운드는 impl에 의해 완전히 함의되거나, 충족될 수 없습니다. 충족될 수 없는 경우에는 어떤 일이 일어나든 크게 신경 쓰지 않습니다. where 바운드가 완전히 함의된다면, impl을 사용해 트레이트 목표를 증명하더라도 추가 제약이 생길 수 없습니다. 트레이트 목표의 경우, 이는 'static을 사용하는 where 바운드에서만 유용합니다:
#![allow(unused)]
fn main() {
trait A {
fn test(&self);
}
fn foo(x: &dyn A)
where
dyn A + 'static: A, // Using this bound would lead to a lifetime error.
{
x.test();
}
}
더 중요한 점은, 여기서 impl을 사용하면 연관 타입을 정규화할 때 전역 where 바운드가 impl을 가리는 것을 막을 수 있다는 것입니다. 전역 where 바운드보다 impl을 우선하는 데서 비롯되는 알려진 문제는 없습니다.
그럼에도 전역 where 바운드를 고려하는 이유
전역 where 바운드가 존재하더라도 그냥 impl을 사용한다면, 왜 이러한 전역 where 바운드를 아예 무시하지 않는지 궁금할 수 있습니다: 비전역 where 바운드로부터의 추론 유도를 약화하는 데 이를 사용하기 때문입니다.
전역 where 바운드가 없으면, 현재는 적용 가능한 impl이 있더라도 비전역 where 바운드를 우선합니다. 비전역 where 바운드를 추가하면 이 불필요한 추론 유도가 비활성화되어, 다음 코드가 컴파일될 수 있습니다:
#![allow(unused)]
fn main() {
fn check<Color>(color: Color)
where
Vec: Into<Color> + Into<f32>,
{
let _: f32 = Vec.into();
// Without the global `Vec: Into<f32>` bound we'd
// eagerly use the non-global `Vec: Into<Color>` bound
// here, causing this to fail.
}
struct Vec;
impl From<Vec> for f32 {
fn from(_: Vec) -> Self {
loop {}
}
}
}
CandidateSource::AliasBound
impl보다 별칭 바운드 후보를 우선합니다. 현재 이 우선순위를 타입 추론을 유도하는 데 사용하며, 그 결과 다음 코드가 컴파일됩니다. 개인적으로는 이 우선순위가 바람직하다고 생각하지 않습니다 🤷
#![allow(unused)]
fn main() {
pub trait Dyn {
type Word: Into<u64>;
fn d_tag(&self) -> Self::Word;
fn tag32(&self) -> Option<u32> {
self.d_tag().into().try_into().ok()
// prove `Self::Word: Into<?0>` and then select a method
// on `?0`, needs eager inference.
}
}
}
fn impl_trait() -> impl Into<u32> {
0u16
}
fn main() {
// There are two possible types for `x`:
// - `u32` by using the "alias bound" of `impl Into<u32>`
// - `impl Into<u32>`, i.e. `u16`, by using `impl<T> From<T> for T`
//
// We infer the type of `x` to be `u32` even though this is not
// strictly necessary and can even lead to surprising errors.
let x = impl_trait().into();
println!("{}", std::mem::size_of_val(&x));
}
이 우선순위는 영역 제약으로 인한 모호성도 피하게 해 주지만, 실제로 사람들이 이에 의존하는지는 알지 못합니다.
#![allow(unused)]
fn main() {
trait Bound<'a> {}
impl<T> Bound<'static> for T {}
trait Trait<'a> {
type Assoc: Bound<'a>;
}
fn impls_bound<'b, T: Bound<'b>>() {}
fn foo<'a, T: Trait<'a>>() {
// Should we infer this to `'a` or `'static`.
impls_bound::<'_, T::Assoc>();
}
}
CandidateSource::BuiltinImpl(BuiltinImplSource::Object(_))
사용자가 작성한 impl보다 내장 트레이트 객체 impl을 우선합니다. 이는 건전하지 않으며 향후 제거되어야 합니다. 자세한 내용은 #57893과 #141347을 참조하십시오.
NormalizesTo 목표
정규화 중의 후보 우선순위 동작은 fn assemble_and_merge_candidates에 구현되어 있습니다.
항상 AliasBound 후보를 고려합니다
where 바운드가 연관 항목을 지정하지 않는 경우, 트레이트 목표가 ParamEnv 후보를 통해 증명되었더라도 별칭을 고정된(rigid) 것으로 취급하는 대신 AliasBound 후보를 고려합니다.
#![allow(unused)]
fn main() {
trait Super {
type Assoc;
}
trait Bound {
type Assoc: Super<Assoc = u32>;
}
trait Trait: Super {}
// Elaborating the environment results in a `T::Assoc: Super` where-bound.
// This where-bound must not prevent normalization via the `Super<Assoc = u32>`
// item bound.
fn heck<T: Bound<Assoc: Trait>>(x: <T::Assoc as Super>::Assoc) -> u32 {
x
}
}
이러한 별칭을 사용하면 추가적인 영역 제약이 생길 수 있습니다. cc #133044.
#![allow(unused)]
fn main() {
trait Bound<'a> {
type Assoc;
}
trait Trait {
type Assoc: Bound<'static, Assoc = u32>;
}
fn heck<'a, T: Trait<Assoc: Bound<'a>>>(x: <T::Assoc as Bound<'a>>::Assoc) {
// Normalizing the associated type requires `T::Assoc: Bound<'static>` as it
// uses the `Bound<'static>` alias-bound instead of keeping the alias rigid.
drop(x);
}
}
AliasBound보다 ParamEnv 후보를 우선합니다
where 바운드가 연관 타입을 지정하지 않으면 AliasBound 후보를 사용하지만, 지정하는 경우에는 where 바운드를 우선합니다. 이는 다음 예제를 위해 필요합니다:
#![allow(unused)]
fn main() {
// Make sure we prefer the `I::IntoIterator: Iterator<Item = ()>`
// where-bound over the `I::Intoiterator: Iterator<Item = I::Item>`
// alias-bound.
trait Iterator {
type Item;
}
trait IntoIterator {
type Item;
type IntoIter: Iterator<Item = Self::Item>;
}
fn normalize<I: Iterator<Item = ()>>() {}
fn foo<I>()
where
I: IntoIterator,
I::IntoIter: Iterator<Item = ()>,
{
// We need to prefer the `I::IntoIterator: Iterator<Item = ()>`
// where-bound over the `I::Intoiterator: Iterator<Item = I::Item>`
// alias-bound.
normalize::<I::IntoIter>();
}
}
항상 where 바운드를 고려합니다
트레이트 목표가 impl을 통해 증명되었더라도, ParamEnv 후보가 존재한다면 여전히 이를 우선합니다.
“고아(orphaned)” where 바운드를 우선합니다
fn check_type_bounds에서 GAT와 RPITIT의 항목 바운드를 정규화할 때 “고아(orphaned)” Projection 절을 ParamEnv에 추가합니다. 이러한 ParamEnv 후보를 impl 및 다른 where 바운드보다 우선해야 합니다.
#![allow(unused)]
#![feature(associated_type_defaults)]
fn main() {
trait Foo {
// We should be able to prove that `i32: Baz<Self>` because of
// the impl below, which requires that `Self::Bar<()>: Eq<i32>`
// which is true, because we assume `for<T> Self::Bar<T> = i32`.
type Bar<T>: Baz<Self> = i32;
}
trait Baz<T: ?Sized> {}
impl<T: Foo + ?Sized> Baz<T> for i32 where T::Bar<()>: Eq<i32> {}
trait Eq<T> {}
impl<T> Eq<T> for T {}
}
이 우선순위가 실제로 필요한 경우를 완전히 이해하지는 못했고 아직 이를 재미있는 방식으로 악용해 보지도 못했습니다만 🤷
전역 where 바운드를 impl보다 우선합니다
이는 다음 코드가 컴파일되기 위해 필요합니다. 실제로 이에 의존하는 코드가 있는지는 모르겠습니다 🤷
#![allow(unused)]
fn main() {
trait Id {
type This;
}
impl<T> Id for T {
type This = T;
}
fn foo<T>(x: T) -> <u32 as Id>::This
where
u32: Id<This = T>,
{
x
}
}
이는 정규화로 인해 추가적인 영역 제약이 생길 수 있음을 의미합니다. cc #133044.
#![allow(unused)]
fn main() {
trait Trait {
type Assoc;
}
impl Trait for &u32 {
type Assoc = u32;
}
fn trait_bound<T: Trait>() {}
fn normalize<T: Trait<Assoc = u32>>() {}
fn foo<'a>()
where
&'static u32: Trait<Assoc = u32>,
{
trait_bound::<&'a u32>(); // ok, proven via impl
normalize::<&'a u32>(); // error, proven via where-bound
}
}
트레이트 where 바운드가 impl을 가립니다
연관 항목의 정규화는 해당 트레이트 목표가 ParamEnv 또는 AliasBound 후보를 통해 증명된 경우 impl을 고려하지 않습니다. 즉, 연관 타입을 제약하지 않는 where 바운드의 경우 연관 타입은 경직된(rigid) 상태로 남습니다.
impl을 사용하면 다른 영역 제약이 생깁니다
이는 impl을 적용하면서 생기는 불필요한 영역 제약을 피하기 위해 필요합니다.
#![allow(unused)]
fn main() {
trait Trait<'a> {
type Assoc;
}
impl Trait<'static> for u32 {
type Assoc = u32;
}
fn bar<'b, T: Trait<'b>>() -> T::Assoc { todo!() }
fn foo<'a>()
where
u32: Trait<'a>,
{
// Normalizing the return type would use the impl, proving
// the `T: Trait` where-bound would use the where-bound, resulting
// in different region constraints.
bar::<'_, u32>();
}
}
RPITIT type_of 순환
현재는 RPITIT의 쿼리 순환을 피하기 위해 where 바운드가 있으면 impl 후보를 피해야 합니다. #139762를 참고하십시오. 이 문제를 없애기 위해 RPITIT 계산 중 자동 트레이트 누출에 의존하지 않도록 하는 것이 바람직하다고 생각합니다. #139788을 참고하십시오.
#![allow(unused)]
fn main() {
use std::future::Future;
pub trait ReactiveFunction: Send {
type Output;
fn invoke(self) -> Self::Output;
}
trait AttributeValue {
fn resolve(self) -> impl Future<Output = ()> + Send;
}
impl<F, V> AttributeValue for F
where
F: ReactiveFunction<Output = V>,
V: AttributeValue,
{
async fn resolve(self) {
// We're awaiting `<V as AttributeValue>::{synthetic#0}` here.
// Normalizing that one via the the impl we're currently in
// relies on `collect_return_position_impl_trait_in_trait_tys` which
// ends up relying on auto-trait leakage when checking that the
// opaque return type of this function implements the `Send` item
// bound of the trait definition.
self.invoke().resolve().await
}
}
}
트레이트 정의에서는 항상 적용 가능한 impl의 연관 타입을 사용할 수 없습니다
트레이트 정의에 있는 T: Trait 가정 때문에 포괄(blanket) impl을 사용하여 <Self as Trait>::Assoc을 T로 정규화할 수 없습니다. 이는 아주 그렇지는 않더라도 어느 정도 바람직한 제약이라고 생각합니다.
#![allow(unused)]
fn main() {
trait Eq<T> {}
impl<T> Eq<T> for T {}
struct IsEqual<T: Eq<U>, U>(T, U);
trait Trait: Sized {
type Assoc;
fn foo() -> IsEqual<Self, Self::Assoc> {
//~^ ERROR the trait bound `Self: Eq<<Self as Trait>::Assoc>` is not satisfied
todo!()
}
}
impl<T> Trait for T {
type Assoc = T;
}
}
표준화
표준화는 값을 그 컨텍스트로부터 분리하는 과정이며, 추론 변수를 포함하는 목표(goal)를 전역적으로 캐싱하는 데 필요합니다.
핵심은 ?x와 ?y가 현재 제약되지 않은 서로 다른 두 추론 변수일 때, u32: Trait<?x>와 u32: Trait<?y>라는 목표가 주어지면 두 목표에 대해 같은 결과를 얻어야 한다는 것입니다. 따라서 정준 쿼리 exists<T> u32: Trait<T>를 한 번 증명하고 그 결과를 재사용할 수 있습니다.
먼저 정준 쿼리가 동작하는 방식을 살펴본 뒤, 표준화가 동작하는 방식의 세부 사항을 자세히 알아봅시다.
정준 쿼리 둘러보기
조금 더 쉽게 설명하기 위해, 관련된 impl이 impl<T> Trait<Vec<T>> for u32 하나뿐이라고 가정하고 트레이트 목표 u32: Trait<?x>를 예로 들어 봅시다.
입력 표준화하기
먼저 목표를 표준화하는 것으로 시작합니다. 이때 추론 변수는 존재 바운드 변수로, 플레이스홀더는 전칭 바운드 변수로 대체합니다. 그 결과 표준 목표 exists<T> u32: Trait<T>를 얻게 됩니다.
원래 컨텍스트에서 모든 바운드 변수가 가졌던 원래 값을 기억해 둡니다. 여기서는 T를 다시 ?x로 매핑하게 됩니다. 이 원래 값들은 나중에 쿼리 응답을 처리할 때 사용됩니다.
이제 정준 목표를 가지고 정준 쿼리를 호출합니다.
쿼리 내부에서 표준 목표 인스턴스화하기
표준 목표를 실제로 증명해 보기 위해, 먼저 바운드 변수를 다시 추론 변수와 플레이스홀더로 인스턴스화합니다.
이 과정은 쿼리 내부의 완전히 별개인 InferCtxt에서 일어납니다. 이제 쿼리 내부에는 u32: Trait<?0>라는 목표가 있습니다. 또한 표준 목표의 바운드 변수를 인스턴스화하는 데 어떤 값을 사용했는지도 기억해 두며, 이는 T를 ?0으로 매핑합니다.
이제 목표 u32: Trait<?0>를 계산하여 이 목표가 성립한다는 것을 알아내지만, 그 과정에서 ?0을 Vec<?1>로 제약했습니다. 마지막으로 이 결과를 호출자에게 유용한 형태로 변환합니다.
쿼리 응답 표준화하기
호출자에게는 목표가 성립하는지 여부와 쿼리 내부에서 생긴 추론 제약을 모두 반환해야 합니다.
추론 결과를 호출자에게 반환하기 위해, 쿼리에서 바운드 변수를 인스턴스화된 값으로 대응시키는 매핑을 표준화합니다. 즉, 쿼리 응답은 Certainty::Yes와 T에서 exists<U> Vec<U>로의 매핑이 됩니다.
쿼리 응답 인스턴스화하기
이제 호출자는 쿼리가 반환한 제약을 적용해야 합니다. 이를 위해 호출자는 먼저 표준 응답의 바운드 변수를 다시 추론 변수와 플레이스홀더로 인스턴스화하며, 따라서 응답의 매핑은 이제 T에서 Vec<?z>로의 매핑이 됩니다.
이제 T의 원래 값(?x)과 응답에 있는 T의 값(Vec<?z>)을 동일시하며, 이를 통해 ?x를 Vec<?z>로 올바르게 제약합니다.
ExternalConstraints
트레이트 목표를 계산하면 추론 변수를 제약할 뿐만 아니라 영역 의무(region obligation)가 추가될 수도 있습니다. 예를 들어 목표 (): AOutlivesB<'a, 'b>가 주어지면 'a: 'b가 성립해야 한다는 사실을 반환하고자 합니다.
이는 쿼리에서 바운드 변수를 인스턴스화된 값으로 대응시키는 매핑을 반환할 뿐만 아니라, 응답을 구성하는 동안 InferCtxt 컨텍스트에서 추가적인 ExternalConstraints를 추출하는 방식으로 이루어집니다.
표준화는 정확히 어떻게 동작합니까
TODO: PR이 병합되면 코드 링크를 추가하고 내용을 보완하십시오
- 타입과 상수: 유니버스를 고려하여 추론 변수는 존재적 바운드 변수로, 플레이스홀더는 보편적 바운드 변수로 매핑합니다.
- 입력에 있는 제네릭 매개변수는 루트 유니버스의 플레이스홀더로 취급됩니다
- 입력에 있는 모든 영역은 전부 존재적 바운드 변수로 매핑되며, 이들을 “고유화“합니다.
&'a (): Trait<'a>는exists<'0, '1> &'0 (): Trait<'1>로 표준화됩니다. 리전의 유니버스는 신경 쓰지 않으며, 단순히 모든 리전을 입력의 가장 높은 유니버스에 넣습니다. - 출력에서는 호출자의 유니버스에 속한 모든 것이 루트 유니버스에 들어가며, 변수 값을 호출자의 원래 값과 통합할 때에야 비로소 올바른 유니버스를 갖게 됩니다
- 응답에서는 리전을 고유화하지 않으며
'static도 정규화하지 않습니다
공귀납
트레이트 솔버는 목표를 증명할 때 공귀납을 사용할 수 있습니다. 공귀납은 꽤 미묘한 개념이므로 별도의 장에서 다룹니다.
공귀납과 귀납
귀납에서는 유한한 증명 트리가 만들어질 때까지 증명을 재귀적으로 적용합니다. 다음과 같은 트리를 만들어 내는 Vec<Vec<Vec<u32>>>: Debug의 예를 살펴보십시오.
Vec<Vec<Vec<u32>>>: DebugVec<Vec<u32>>: DebugVec<u32>: Debugu32: Debug
이 트리는 유한합니다. 그러나 성립하기를 바라는 모든 목표가 유한한 증명 트리를 갖는 것은 아닙니다. 다음 예를 살펴보십시오.
#![allow(unused)]
fn main() {
struct List<T> {
value: T,
next: Option<Box<List<T>>>,
}
}
List<T>: Send가 성립하려면 그 모든 필드도 재귀적으로 Send를 구현해야 합니다. 그러면 다음과 같은 증명 트리가 만들어집니다.
List<T>: SendT: SendOption<Box<List<T>>>: SendBox<List<T>>: SendList<T>: SendT: SendOption<Box<List<T>>>: SendBox<List<T>>: Send- …
이 트리는 무한히 커지며, 바로 이것이 공귀납이 다루는 대상입니다.
목표를 귀납적으로 증명하려면 그 목표에 대한 유한한 증명 트리를 제시해야 합니다. 목표를 공귀납적으로 증명할 때는 제시하는 증명 트리가 무한해도 됩니다.
공귀납이 올바른 이유
어떤 트레이트 목표가 성립하는지 검사할 때, 우리는 “이 바운드를 만족하는 impl이 존재하는가“를 묻는 것입니다. 중첩된 목표가 무한히 이어지더라도, 사용해야 할 impl은 여전히 하나로 정해집니다.
공귀납을 구현하는 방법
무한한 트리를 구성하려면 무한한 자원이 필요하므로 우리 구현은 그런 방식으로 공귀납을 검사할 수 없지만, 그래도 이러한 관점에서 공귀납을 생각하는 것은 의미가 있습니다.
무한 트리를 검사할 수는 없으므로, 대신 무한 증명 트리를 만들어 낸다고 알려진 패턴을 찾습니다. 현재 감지하는 패턴은 (정규화된) 순환입니다. T: Send가 T: Send에 의존한다면, 이것이 끝없이 계속되리라는 것은 매우 분명합니다.
순환이 있을 때는 캐싱에 주의해야 합니다. 영역과 추론 변수의 표준화 때문에, 순환을 만났다고 해서 무한한 증명 트리가 생기는 것은 아닙니다. 다음 예제를 살펴보십시오.
#![allow(unused)]
fn main() {
trait Foo {}
struct Wrapper<T>(T);
impl<T> Foo for Wrapper<Wrapper<T>>
where
Wrapper<T>: Foo
{}
}
Wrapper<?0>: Foo를 증명할 때는 impl<T> Foo for Wrapper<Wrapper<T>> impl을 사용하는데, 이 impl은 ?0를 Wrapper<?1>로 제약한 다음 Wrapper<?1>: Foo를 요구합니다. 표준화 때문에 이것은 순환으로 감지됩니다.
해결 방안은 순환을 감지할 때마다 잠정 결과를 반환하고, 잠정 결과가 해당 목표의 최종 결과와 같아질 때까지 목표를 반복해서 다시 시도하는 것입니다. 처음에는 제약이 없는 Yes를 결과로 사용하고, 다시 실행해야 할 때마다 이를 이전 반복의 결과로 갱신합니다.
TODO: 여기에 자세한 설명을 추가해야 합니다. 공귀납적 순환에 대해서는 chalk와 같은 접근 방식을 사용합니다. 귀납적 순환은 현재 단순히 Overflow를 반환한다는 점에서 처리 방식이 다르다는 것에 유의하십시오. chalk 책의 관련 장을 참고하십시오.
향후 작업
현재는 자동 트레이트, Sized, WF 목표만 공귀납적인 것으로 간주합니다. 앞으로는 사실상 모든 목표를 공귀납적으로 만들 계획입니다. 먼저 더 많은 공귀납적 증명을 허용하는 것이 애초에 왜 바람직한지 자세히 설명하겠습니다.
재귀 데이터 타입은 이미 공귀납에 의존하고 있습니다…
…트레이트 솔버에서는 그것들을 피하는 경향이 있을 뿐입니다.
#![allow(unused)]
fn main() {
enum List<T> {
Nil,
Succ(T, Box<List<T>>),
}
impl<T: Clone> Clone for List<T> {
fn clone(&self) -> Self {
match self {
List::Nil => List::Nil,
List::Succ(head, tail) => List::Succ(head.clone(), tail.clone()),
}
}
}
}
이 impl에서는 tail.clone()을 사용하고 있습니다. 이를 위해 Box<List<T>>: Clone을 증명해야 하며, 이는 List<T>: Clone을 요구합니다. 그런데 이는 현재 검사 중인 바로 그 impl에 의존합니다. perfect derive에서 하듯이 그 요구 사항을 impl의 where 절에 추가하면, 그 순환이 트레이트 솔버 안으로 옮겨지고 오류가 발생합니다.
재귀 데이터 타입
프로젝션을 포함하는 재귀 타입을 추론하는 데에도 공귀납이 필요합니다. 예를 들어 다음 코드는 유효해야 함에도 현재 컴파일에 실패합니다.
#![allow(unused)]
fn main() {
use std::borrow::Cow;
pub struct Foo<'a>(Cow<'a, [Foo<'a>]>);
}
이 문제는 적어도 2015년부터 알려져 있었으며, 더 알고 싶다면 #23714를 참고하십시오.
명시적으로 검사되는 암시적 바운드
impl을 검사할 때, 우리는 impl 헤더에 있는 타입이 적격이라고 가정합니다. 이는 impl을 인스턴스화하여 사용할 때 실제로 그러한지 증명해야 한다는 뜻입니다. #100051은 실제로는 그렇지 않다는 것을 보여 줍니다. 이를 고치려면 impl 헤더에 있는 타입들에 대해 WF 술어를 추가해야 합니다. 모든 트레이트에 대한 공귀납이 없으면 이것은 core마저 망가뜨립니다.
#![allow(unused)]
fn main() {
trait FromResidual<R> {}
trait Try: FromResidual<<Self as Try>::Residual> {
type Residual;
}
struct Ready<T>(T);
impl<T> Try for Ready<T> {
type Residual = Ready<()>;
}
impl<T> FromResidual<<Ready<T> as Try>::Residual> for Ready<T> {}
}
FromResidual의 impl이 올바른 형식(well-formed)인지 검사할 때 다음과 같은 순환이 발생합니다:
이 impl은 <Ready<T> as Try>::Residual과 Ready<T>가 올바른 형식이면 올바른 형식입니다.
wf(<Ready<T> as Try>::Residual)는 다음을 요구합니다Ready<T>: Try이며, 이는 슈퍼 트레이트 때문에 다음을 요구합니다Ready<T>: FromResidual<Ready<T> as Try>::Residual>, impl에 대한 암시적 바운드 때문에wf(<Ready<T> as Try>::Residual):tada: 순환
공귀납을 더 많은 목표로 확장할 때의 문제점
공귀납을 확장할 때 염두에 두어야 할 몇 가지 추가적인 문제가 있습니다. 여기서 다루는 문제는 현재 솔버와는 관련이 없습니다.
암시적 슈퍼 트레이트 바운드
현재 우리의 트레이트 시스템은 슈퍼 트레이트(예: trait Trait: SuperTrait)를 1) Trait를 구현하는 모든 타입에 대해 SuperTrait가 성립해야 한다고 요구하고, 2) Trait가 성립하면 SuperTrait도 성립한다고 가정하는 방식으로 다룹니다.
1)을 증명하는 동안 2)에 의존하는 것은 건전하지 않습니다. 이는 공귀납적 순환이 있는 경우에만 관찰될 수 있습니다. 순환이 없다면, 2)에 의존할 때마다 사용된 Trait의 impl에 대해 2)에 의존하지 않고 1)도 이미 증명했어야 합니다.
#![allow(unused)]
fn main() {
trait Trait: SuperTrait {}
impl<T: Trait> Trait for T {}
// Keeping the current setup for coinduction
// would allow this compile. Uff :<
fn sup<T: SuperTrait>() {}
fn requires_trait<T: Trait>() { sup::<T>() }
fn generic<T>() { requires_trait::<T>() }
}
이는 공귀납 자체의 근본적인 문제라기보다는, 기존에 있던 속성이 공귀납 때문에 건전하지 않게 된 것입니다.
가능한 해결책
이를 해결하는 가장 쉬운 방법은 2)를 완전히 제거하고, 트레이트 솔버 외부에서 T: Trait를 항상 T: Trait와 T: SuperTrait로 확장(elaborate)하는 것입니다. 이렇게 하면 1)도 제거할 수 있지만, 트레이트에 대한 일반적인 where 바운드는 여전히 증명해야 하므로 이는 추가 작업일 뿐입니다.
1)을 검사할 때 2)의 순환적 사용을 비활성화하는 방법을 생각해 볼 수는 있지만, 적어도 저(@lcnr)의 아이디어는 모두 합리적이라고 하기에는 지나치게 복잡합니다.
normalizes_to 목표와 진전
normalizes_to 목표는 <T as Trait>::Assoc가 어떤 U로 정규화된다는 요구 사항을 나타냅니다. 이는 사실상 먼저 <T as Trait>::Assoc를 정규화한 다음, 그 결과 타입을 U와 동일시하는 방식으로 이루어집니다. 각 프로젝션은 정확히 하나의 타입으로 정규화되어야 하므로 이는 사상(mapping)이어야 합니다. 단순히 무한한 증명 트리를 허용하면 다음과 같은 동작이 발생합니다:
#![allow(unused)]
fn main() {
trait Trait {
type Assoc;
}
impl Trait for () {
type Assoc = <() as Trait>::Assoc;
}
}
이제 normalizes_to(<() as Trait>::Assoc, Vec<u32>)를 계산하면, impl을 해석하여 연관 타입 <() as Trait>::Assoc를 얻게 됩니다. 그런 다음 이를 기대 타입과 동일시하므로 normalizes_to(<() as Trait>::Assoc, Vec<u32>)를 다시 검사하게 됩니다. 이 과정은 끝없이 계속되어 무한한 증명 트리가 만들어집니다.
이는 <() as Trait>::Assoc가 다른 어떤 타입과도 같아진다는 뜻이며, 이는 건전하지 않습니다.
해결 방법
경고: 이 내용은 미묘하며 틀렸을 수 있습니다
트레이트 목표와 달리 normalizes_to는 생산적(productive)1이어야 합니다. normalizes_to 목표는 프로젝션이 강체(rigid) 타입 생성자로 정규화되면 생산적이므로, <() as Trait>::Assoc가 Vec<<() as Trait>::Assoc>로 정규화되는 것은 생산적입니다.
normalizes_to 목표에는 두 종류의 중첩된 목표가 있습니다. 하나는 프로젝션을 실제로 정규화하는 데 필요한 중첩 요구 사항이고, 다른 하나는 정규화된 프로젝션과 기대 타입 사이의 동등성입니다. 생산적이어야 하는 것은 동등성뿐입니다. 증명 트리의 분기는 유한하거나, 별칭이 강체(rigid) 타입 생성자로 해소되는 normalizes_to를 적어도 하나 포함하면 생산적입니다.
또는 normalizes_to의 동등화 분기를 항상 귀납적으로 취급할 수도 있습니다. 모든 순환은 무한 타입을 낳을 것입니다. 무한 타입은 어차피 지원되지 않으며, 코드 생성을 위해 깊이 정규화할 때 오버플로만 일으킬 것입니다.
실험과 예시: https://hackmd.io/-8p0AHnzSq2VAE6HE_wX-w?view
요약을 한 번 더 시도해 봅니다.
- 프로젝션 동등성에서는 rhs를 제약하는 쪽으로 반드시 진전을 이루어야 합니다.
- 순환은 동등화하는 동안 정규화 후 lhs에 강체 타입이 적어도 한 번 나타나는 경우에만 괜찮습니다.
normalizes_to의 재귀적인eq호출 바깥의 순환은 항상 괜찮습니다.
새 트레이트 솔버의 캐싱
성능을 위해서는 트레이트 솔버의 결과를 캐싱해야 합니다. 이때 캐싱이 건전(sound)하도록 보장해야 합니다. 캐싱은 SearchGraph가 처리합니다.
전역 캐시
기본적으로 캐시는 상당히 단순합니다. 목표(goal)를 평가할 때 그 목표가 전역 캐시에 있는지 확인합니다. 있다면 해당 항목을 재사용합니다. 없다면 목표를 계산한 다음 그 결과를 캐시에 저장합니다.
증분 컴파일을 처리하기 위해 목표의 계산은 DepGraph::with_anon_task 내부에서 이루어지며, 이 함수는 해당 계산 내부에서 사용된 모든 쿼리에 의존하는 새로운 DepNode를 생성합니다. 그런 다음 전역 캐시에 접근할 때 이 DepNode를 읽어, 사용된 모든 쿼리에 대한 의존성 간선을 수동으로 추가합니다: 소스.
오버플로 처리
새 트레이트 솔버에서는 재귀 한도에 도달해도 치명적인 오류가 되지 않으며, 대신 단순히 모호성(ambiguity)을 반환합니다: 소스. 따라서 재귀 한도 도달 여부는 컴파일 실패를 일으키지 않으면서도 결과를 바꿀 수 있습니다. 즉, 캐시된 결과에 접근할 때 남아 있는 사용 가능한 깊이를 고려해야 합니다.
이를 위해 캐시 항목에 더 많은 정보를 저장합니다. 평가가 재귀 한도에 도달하지 않은 목표의 경우에는 도달한 깊이만 저장합니다: 소스. 이러한 결과는 현재의 available_depth가 해당 reached_depth보다 큰 한 자유롭게 사용할 수 있습니다: 소스. 그런 다음 전역 캐시 항목을 사용했는지 여부가 관찰되지 않도록 현재 목표의 도달 깊이를 갱신합니다: 소스.
재귀 한도에 도달한 목표의 경우, 현재는 사용 가능한 깊이가 항목의 깊이와 정확히 일치할 때에만 캐시된 결과를 사용합니다. 따라서 각 목표의 캐시 항목에는 남은 깊이마다 별도의 결과가 들어 있습니다: 소스.1
순환 처리
트레이트 솔버는 순환을 지원해야 합니다. 이러한 순환은 참여하는 목표에 따라 귀납적이거나 공귀납적입니다. 자세한 내용은 공귀납에 관한 장을 참고하십시오. 순환 헤드(cycle head)와 순환 루트(cycle root)는 서로 구분됩니다. 스택 항목이 재귀적으로 접근되면 그 항목은 순환 헤드입니다. 루트는 어떤 순환에든 관여하는 목표 중 스택에서 가장 깊은 목표입니다. 다음 의존성 트리에서 A와 B는 모두 순환 헤드이지만, 루트는 A뿐입니다.
graph TB
A --> B
B --> C
C --> B
C --> A
사이클 참여자의 결과는 아직 스택에 있는 목표의 결과에 따라 달라집니다. 그러나 지금 그 결과를 계산하는 중이므로 그 결과는 아직 알 수 없습니다. 이 문제는 고정점에 도달할 때까지 사이클 헤드를 평가하는 방식으로 처리합니다. 첫 번째 반복에서는 사이클이 공귀납적인지에 따라 제약 조건 없는 성공 또는 오버플로를 반환합니다: 소스. 사이클의 헤드를 평가한 뒤에는 그 provisional_result가 이번 반복의 결과와 같은지 확인합니다. 같다면 이 사이클의 평가를 마친 것이므로 그 결과를 반환합니다. 같지 않다면 임시 결과를 갱신하고 목표를 다시 평가합니다: 소스. 첫 번째 반복 이후에는 사이클이 공귀납적인지 귀납적인지가 중요하지 않습니다. 항상 임시 결과를 사용합니다.
사이클 루트만 캐싱하기
사이클 루트의 평가를 마치기 전까지는 어떤 사이클 참여자의 결과도 전역 캐시로 옮길 수 없습니다. 그러나 사이클을 완전히 평가한 뒤에도 루트 자신을 제외한 모든 참여자의 결과는 여전히 버려야 합니다.
모든 전역 캐시 항목의 쿼리 의존성을 추적합니다. 이 때문에 사이클 참여자를 캐싱하는 일은 간단하지 않습니다. 사이클 루트의 DepNode를 그대로 재사용할 수는 없습니다.2 사이클 A -> B -> A가 있다면 A에 대한 DepNode에는 A -> B 의존성이 포함됩니다. 이 항목을 B에 재사용하면 소스가 변경되었을 때 문제가 생길 수 있습니다. B -> A 간선이 더 이상 존재하지 않을 수도 있고, A가 완전히 제거되었을 수도 있습니다. 이는 쉽게 ICE로 이어질 수 있습니다.
그러나 어떤 목표가 루트인지에 따라 사이클의 결과가 달라질 수 있으므로 상황은 더 나쁩니다: 예제. 이 때문에 캐싱을 더욱 약화할 수밖에 없습니다. 어떤 사이클 루트가 포함된 사이클의 참여자였던 스택 항목이 존재한다면, 그 사이클 루트의 캐시 항목을 사용해서는 안 됩니다. 이를 위해 주어진 루트의 모든 사이클 참여자를 그 루트의 전역 캐시 항목에 저장하고, 그 항목에 스택의 원소가 하나도 포함되지 않았는지 확인합니다: 소스.
임시 캐시
TODO: 이 부분을 작성해야 합니다 :3
- 임시 결과의 스택 의존성
- 엣지 케이스: 임시 캐시가 동작에 영향을 미치는 경우
증명 트리
트레이트 솔버 자체는 목표가 성립하는지 여부와 필요한 제약 조건만 반환하지만, 때로는 그 목표를 증명하는 과정에서 무슨 일이 일어났는지도 알아야 할 때가 있습니다. 트레이트 솔버는 일반적으로 컴파일러의 나머지 부분에서 블랙박스로 취급되어야 하지만, 그 내부를 완전히 무시할 수는 없으므로 이를 위한 인터페이스로 “증명 트리“를 제공합니다. 이를 사용하려면 ProofTreeVisitor 트레이트를 구현하십시오. 예시는 기존 구현을 참고하십시오. 가장 대표적인 용도는 일관성 오류에 대한 크레이트 간 모호성 원인 계산, 트레이트 솔버 오류 개선, 그리고 클로저 시그니처의 조기 추론입니다.
증명 트리 계산하기
트레이트 솔버는 표준화를 사용하며, 각 중첩된 목표마다 완전히 별개의 InferCtxt를 사용합니다. 진단과 rustdoc의 자동 트레이트 모두 “중첩된 목표 들여다보기“를 올바르게 처리해야 합니다. Vec<Vec<?x>>: Debug와 같은 목표가 주어지면, 이를 exists<T0> Vec<Vec<T0>>: Debug로 정규화하고, 그 목표를 Vec<Vec<?0>>: Debug로 인스턴스화하여 중첩된 목표 Vec<?0>: Debug를 얻습니다. 이를 다시 정규화하여 exists<T0> Vec<T0>: Debug를 얻은 뒤 Vec<?0>: Debug로 인스턴스화하면, 결국 모호한 중첩 목표 ?0: Debug가 생깁니다.
트레이트 솔버의 평가 단계를 트리로 변환하는 탐색 그래프에 ProofTreeBuilder를 전달하여 증명 트리를 계산합니다. 추론 변수나 플레이스홀더를 사용하는 데이터를 저장할 때, 해당 데이터는 이 계산 중에 생성된 모든 제약되지 않은 추론 변수의 목록과 함께 정규화됩니다. 그런 다음 증명 트리를 순회하는 동안 이 CanonicalState가 부모 추론 컨텍스트에서 인스턴스화되며, 이때 추론 변수 목록을 사용하여 이 평가 중에 생성된 모든 정규화된 값을 연결합니다.
솔버 디버깅하기
이전에는 솔버 구현을 디버깅하는 데에도 증명 트리를 사용하려고 시도했습니다. 이는 솔버를 프로그래밍 방식으로 분석하는 것과는 설계 요구 사항이 다릅니다. 트레이트 솔버를 디버깅하는 권장 방법은 tracing을 사용하는 것입니다. 트레이트 솔버는 전반적인 ’형태’에는 debug 추적 수준만 사용하고, 추가적인 세부 사항에는 trace를 사용합니다. 따라서 RUSTC_LOG=rustc_next_trait_solver=debug는 전체적인 개요를 제공하며, 더 정밀한 정보가 필요한 경우에는 RUSTC_LOG=rustc_next_trait_solver=trace를 사용할 수 있습니다.
새 솔버의 불투명 타입
새 솔버에서 불투명 타입을 처리하는 방식은 기존 구현과 다릅니다. 이 문서는 새 솔버의 동작을 다른 자료 없이도 이해할 수 있도록 설명합니다.
비정의 사용과 정의 사용
비정의 사용과 정의 사용의 구분은 추론 동작을 결정하고 추상화의 엄격성을 좌우합니다. 정의 사용은 그 밑에 숨겨진 타입을 드러냅니다. 반면 비정의 사용은 타입을 추상적인 자리 표시자로 취급하여 경직성을 강제합니다.
비정의 사용
이 개념이 불투명 타입을 경직되게 만듭니다. 솔버는 그 밑의 타입을 알 수 없는 것으로 취급하여 추상화를 유지합니다.
#![allow(unused)]
fn main() {
fn foo() -> impl Copy {
let x: u32 = 56;
x
}
fn bar() {
let x = foo();
// Even though we know 'foo' returns a u32.The solver enforces
// rigidity here and is a NON-DEFINING use.
}
}
정의 사용
불투명 타입이 실제로 정의되는 곳이 바로 여기입니다. 정의 사용은 추상화 뒤에 있는 구체 타입이 정확히 무엇인지 컴파일러에 알려 줍니다.
#![allow(unused)]
fn main() {
fn foo() -> impl Copy {
let x: u32 = 56;
// The return value x defines the hidden type as u32 and is a DEFINING use.
x
}
}
불투명 타입은 별칭 타입입니다
불투명 타입은 가능한 한 다른 별칭, 특히 연관 타입과 똑같이 취급됩니다. 동작상의 차이는 가능한 한 적어야 합니다.
불투명 타입은 숨겨진 타입으로 정규화될 수 있습니다. 또한 완전성에 대한 요구 사항도 같습니다. 이런 점에서 다른 별칭 타입과 매우 비슷하므로, 이렇게 취급하는 것이 바람직합니다. 이렇게 취급하면 코드를 공유하게 되므로 타입 시스템의 복잡성도 줄어듭니다. 불투명 타입을 따로 다뤄야 한다면 규칙이 더 복잡해지고 새로운 종류의 상호작용이 생깁니다. 암시적 부정 모드에서는 불투명 타입을 다른 별칭처럼 취급해야 합니다. 따라서 모드마다 동작이 크게 다르면 그만큼 복잡성이 늘어납니다.
열린 질문: 여기서 다른 접근 방식이 있을 수 있습니까? 예를 들어 불투명 타입을 경직된 타입에 더 가깝게 취급하고, 인스턴스화할 수 있는 위치를 더 제한하는 방식은 어떻습니까? 그렇게 하더라도 일관성 검사 중에는 여전히 일반적인 별칭이어야 합니다
불투명 타입에 대한 normalizes-to
새 솔버는 normalizes-to를 사용하여 별칭의 한 단계 정규화 동작을 정의합니다. 예를 들어 <<T as IdInner>::Assoc as IdOuter>::Assoc는 먼저 <T as IdInner>::Assoc로 정규화되고, 이것이 다시 T로 정규화됩니다. 이것은 정규화 대상인 AliasTy와 기대되는 Term을 모두 받습니다. 실제 정규화에 normalizes-to를 사용하려면 기대되는 항으로 제약 없는 추론 변수를 넘기기만 하면 됩니다.
정의 스코프 안의 불투명 타입과 암묵적 부정(implicit-negative) 일관성 모드에서는 이 작업이 항상 두 단계로 수행됩니다. 정의 스코프 밖에서는 불투명 타입에 대한 normalizes-to가 항상 Err(NoSolution)을 반환합니다.
먼저 기대되는 타입을 숨겨진 타입으로 할당해 봅니다.
현재 암시적 부정 일관성 모드에서는 이 단계가 불투명 타입 저장소와 상호작용하지 않고 항상 모호성으로 끝납니다. 대신 모든 불투명 타입을 ’정의’할 수 있게 허용하고 마지막에 추론된 타입을 버리는 방법도 있습니다. 다만 이렇게 하면 일관성 검사 중에 불투명 타입이 여러 번 사용될 때의 동작이 달라집니다: 예시
정의 스코프 안에서는 먼저 불투명 타입의 타입 인자와 상수 인자가 모두 플레이스홀더인지 확인합니다: 소스. 이 검사가 모호하면 모호성을 반환하고, 실패하면 Err(NoSolution)을 반환합니다. 이 검사는 borrowck의 마지막 단계에서만 검사되는 리전(region)을 무시합니다. 검사가 성공하면 계속 진행합니다.
그다음 불투명 타입의 제네릭 인자를 불투명 타입 저장소에 이미 있는 불투명 타입의 인자와 의미적으로 단일화할 수 있는지 확인합니다. 단일화할 수 있다면, 이전에 저장된 타입을 이 normalizes-to 호출의 기대 타입과 단일화합니다: 소스1.
그렇지 않으면 기대 타입을 불투명 타입 저장소에 삽입합니다: 소스2. 마지막으로, opaque 타입의 항목 바운드가 기대 타입에 대해 성립하는지 확인합니다: 소스.
정규화 가능한 별칭의 별칭 바운드(alias-bounds) 사용
https://github.com/rust-lang/trait-system-refactor-initiative/issues/77
연관 타입은 ParamEnv 후보를 통해 정규화했을 때의 결과 타입보다 더 강한 바운드를 가질 수 있으므로, 정규화 가능한 별칭에 AliasBound 후보를 사용하는 것은 일반적으로 불가능합니다.
이러한 후보를 사용하면 정확한 정규화 전략이 사용자에게 드러나게 됩니다. 그렇지 않은 경우에는 즉시 정규화하는지 여부를 사실상 관찰할 수 없습니다. 어디에서 정규화할지는 기존 솔버 지원을 제거한 뒤에 바꾸고 싶어질 가능성이 높으므로, 그렇게 되는 것은 바람직하지 않습니다.
불투명 타입은 어디에서나 정의될 수 있습니다
정의 스코프 안의 불투명 타입은 단순히 타입을 관계 지을 때든 트레이트 솔버 안에서든 어디에서나 정의될 수 있습니다. 이로써 순서 의존성과 불완전성이 제거됩니다. 이것이 없으면 미묘한 이유로 목표의 결과가 달라질 수 있습니다. 예를 들어 불투명 타입의 첫 번째 정의 사용 전에 그 불투명 타입을 사용하는 목표를 평가하려 하는지에 따라 결과가 달라질 수 있습니다.
정의 스코프 안의 고차 불투명 타입
이는 지원되지 않으며, 현재 이를 정의하려고 하면 항상 오류가 발생해야 합니다.
FIXME: 이제 불투명 타입 저장소에서 불투명 타입을 조회할 때 영역이 단일화될 수 있으므로, 불투명 타입이 플레이스홀더를 참조하지 않는지 미리 확인해야 합니다. 그렇지 않으면 결국 플레이스홀더가 누출됩니다.
멤버 제약
멤버 제약의 처리 방식은 새 솔버에서도 바뀌지 않습니다. 이에 대해서는 기존 관련 장을 참고하십시오.
불투명 타입에 대한 메서드 호출
FIXME: 정의 스코프 안에서 아직 제약되지 않은 불투명 타입에 대한 메서드 호출을 계속 지원해야 합니다. 이를 가장 잘 수행하는 방법은 아직 명확하지 않습니다.
#![allow(unused)]
fn main() {
use std::future::Future;
use futures::FutureExt;
fn go(i: usize) -> impl Future<Output = ()> + Send + 'static {
async move {
if i != 0 {
// This returns `impl Future<Output = ()>` in its defining scope,
// we don't know the concrete type of that opaque at this point.
// Currently treats the opaque as a known type and succeeds, but
// from the perspective of "easiest to soundly implement", it would
// be good for this to be ambiguous.
go(i - 1).boxed().await;
}
}
}
}
주요 변경 사항과 특이점
아래 항목 중 일부는 이미 따로 언급되었지만, 이 페이지에서는 이전 트레이트 시스템 구현에서 달라진 주요 변경 사항을 정리합니다. 또한 솔버가 이상적인 구현과 크게 다른 몇 가지 지점도 다룹니다. 이 문서는 내용을 단순화하였으며 엣지 케이스는 다루지 않습니다. 각 서술에 “대체로“라는 말이 암묵적으로 붙어 있다고 여기시기를 권장합니다.
표준화
새 솔버는 중첩된 목표를 평가할 때 표준화를 사용합니다. 후보가 여러 개일 수 있는 경우에는 각 후보를 즉시 표준화합니다. 그런 다음 그 표준 응답들을 병합하려고 시도합니다. 이는 트레이트 시스템 내부에서 표준화를 사용하지 않는 이전 구현과 다른 점입니다.
이는 두 솔버의 설계에 몇 가지 큰 영향을 미칩니다. 표준화를 사용하여 후보의 제약 조건을 보관해 두지 않으면, 후보 선택 과정에서 각 후보의 제약 조건을 버려야 하며, 후보가 선택된 후 그 후보를 다시 평가해야만 제약 조건을 적용할 수 있습니다: 소스. 표준화 없이는 목표를 평가하여 얻은 추론 제약 조건을 캐시하는 것도 불가능합니다. 이 때문에 이전 구현에는 evaluate와 fulfill이라는 두 가지 시스템이 존재합니다. 평가는 캐시되고, 추론 제약 조건을 적용하지 않으며, 후보를 선택할 때 사용됩니다. 이행은 추론 제약 조건과 영역 제약 조건을 적용하며, 캐시되지 않고, 추론 제약 조건을 적용합니다.
표준화를 사용하면 새 구현은 평가와 이행을 하나로 합칠 수 있으므로, 복잡성과 미묘한 동작 차이를 피할 수 있습니다. 이는 캐싱을 크게 단순화하고, 추적되지 않는 정보에 실수로 의존하는 것을 방지합니다. 또한 선택 후 후보를 다시 평가하지 않아도 되며, 여러 후보의 응답을 병합할 수 있게 합니다. 그러나 평가 중에 목표를 표준화하기 때문에, 새 구현은 트레이트 해결 중 순환을 만났을 때 고정점 알고리즘을 사용해야 합니다: 소스.
지연된 별칭 동등성
새 구현은 별칭들을 관계 지을 때 AliasRelate 목표를 생성하는 반면, 이전 구현은 별칭들을 구조적으로 관계 짓습니다. 이를 통해 새 솔버는 관련된 별칭들을 정규화할 수 있을 때까지 동등성 판단을 보류할 수 있습니다.
기존 솔버의 동작은 불완전하며, 모호한 별칭을 추론 변수로 대체하는 즉시 정규화에 의존합니다. 바운드 변수를 포함하는 별칭에는 이 방식을 쓸 수 없기 때문에, 기존 구현은 바인더 내부의 별칭을 올바르게 처리하지 못합니다. 예를 들어 #102048이 있습니다. 자세한 내용은 정규화 장을 참고하십시오.
중첩된 목표의 즉시 평가
새 구현은 중첩된 목표를 호출자에게 반환하지 않고 즉시 처리합니다. 이전 구현은 두 가지 방식을 모두 사용합니다. 평가에서는 중첩된 목표가 즉시 처리되는 반면, 이행에서는 중첩된 목표를 단순히 나중에 처리하도록 반환합니다.
새 구현은 후보 선택을 위해 중첩된 목표를 즉시 처리할 수 있어야 하므로, 항상 그렇게 처리하면 복잡성이 줄어듭니다. 이를 통해 향후 더 많은 후보를 병합할 수 있게 될 수도 있습니다.
중첩된 목표는 고정점에 도달할 때까지 평가됩니다
새 구현은 고정점에 도달할 때까지 항상 루프를 돌며 목표를 평가합니다. 이전 구현은 이행(fulfillment)에서만 그렇게 하고, 평가(evaluation)에서는 그렇게 하지 않습니다. 항상 이렇게 하면 추론이 강화되고 트레이트 솔버의 순서 의존성이 줄어듭니다. trait-system-refactor-initiative#102를 참조하십시오.
증명 트리와 진단 정보 제공
새 구현은 진단 정보를 직접 추적하지 않고, 대신 관련 정보를 지연 계산하는 데 쓰이는 증명 트리를 제공합니다. 이 부분은 아직 완전히 다듬어지지 않았으며 다소 임시방편적입니다. 이는 성능을 높이기 위해 정상 경로에서 이 정보를 추적하지 않고, 실수로 진단 데이터에 의존하여 동작이 결정되는 일을 막기 위한 것입니다.
새 구현의 주요 특이점
환경 후보가 하나라도 있으면 impl 숨기기
어떤 Trait 목표를 증명할 ParamEnv 또는 AliasBound 후보가 하나라도 있으면, Trait 목표와 Projection 목표 모두에 대해 모든 impl 후보를 버립니다: 소스. 이렇게 하면 사용자가 where 바운드로 완전히 포괄되는 impl을 사용하지 못하게 되며, 이는 이전 구현의 동작과 일치하고 몇 가지 이상한 오류(예: trait-system-refactor-initiative#76)를 피할 수 있게 해 줍니다.
NormalizesTo 목표는 함수입니다
정규화 장을 참조하십시오. 기대 항이 정규화에 영향을 주지 않도록, NormalizesTo 목표를 계산하기 전에 기대 항을 제약되지 않은 추론 변수로 대체합니다. 이는 NormalizesTo 목표가 다른 모든 종류의 목표와 다소 다르게 처리되며 추가적인 솔버 지원이 필요하다는 뜻입니다. 가장 눈에 띄는 점은 이들의 모호한 중첩된 목표가 호출자에게 반환되고, 호출자가 이를 평가한다는 것입니다. 자세한 내용은 #122687을 참조하십시오.
rust-analyzer와 트레이트 솔버 공유하기
rust-analyzer는 컴파일러 프런트엔드로 볼 수 있습니다. rust-analyzer는 파싱, 렉싱, AST 구성 및 로워링, HIR 로워링, 그리고 제한적인 MIR 빌드와 상수 평가까지, rustc에서 코드 생성 이전에 실행되는 부분과 유사한 작업을 수행합니다.
하지만 rust-analyzer는 기본적으로 언어 서버이기 때문에, 그 아키텍처는 몇 가지 중요한 측면에서 rustc의 아키텍처와 다릅니다. 이러한 차이에도 불구하고, rust-analyzer가 맡은 역할 중 상당 부분, 특히 타입 추론과 트레이트 해결은 컴파일러와 겹칩니다.
중복을 피하고 두 구현 간의 일관성을 유지하기 위해, rust-analyzer는 rustc의 여러 크레이트를 재사용하며 가능한 한 공유 추상화에 의존합니다.
공유 크레이트
현재 rust-analyzer는 컴파일러의 다음 rustc_* 크레이트들에 의존합니다.
rustc_abirustc_ast_irrustc_indexrustc_lexerrustc_next_trait_solverrustc_parse_formatrustc_pattern_analysisrustc_type_ir
이 크레이트들은 컴파일러의 일반적인 배포 과정에서 crates.io에 게시되지 않으므로, rust-analyzer는 자체 게시 파이프라인을 유지합니다. rust-analyzer는 rustc-auto-publish 스크립트를 사용하여 이 크레이트들에 ra-ap-rustc_* 접두사를 붙여 crates.io에 게시합니다(예: https://crates.io/crates/ra-ap-rustc_next_trait_solver). 그런 다음 rust-analyzer는 자체 빌드에서 이렇게 다시 게시된 크레이트들에 의존합니다.
특히 트레이트 해결과 관련해서는 rustc_type_ir와 rustc_next_trait_solver가 주요 공유 크레이트이며, 이 크레이트들은 두 컴파일러 프런트엔드가 모두 사용하는 핵심 IR과 솔버 로직을 제공합니다.
추상화 계층
rust-analyzer는 언어 서버이므로 자주 변경되는 소스 코드와 부분적으로 유효하지 않거나 불완전한 소스 코드를 처리해야 합니다. 이를 위해서는 rustc와 상당히 다른 인프라가 필요하며, 특히 소스 코드와 HIR 사이의 계층(예: Ty와 이를 뒷받침하는 인터너)에서 그러합니다.
이러한 차이를 메우기 위해, 컴파일러는 rustc와 rust-analyzer가 공유하는 추상화 계층으로 rustc_type_ir를 제공합니다. 이 크레이트는 타입, 술어, 그리고 트레이트 솔버에 필요한 컨텍스트를 표현하는 데 사용되는 기본 인터페이스를 정의합니다. rustc와 rust-analyzer는 모두 각자의 구체적인 타입 표현에 대해 이 트레이트들을 구현하며, rustc_next_trait_solver는 이러한 추상화에 대해 제네릭하도록 작성되어 있습니다.
이러한 인터페이스 외에도, rustc_type_ir에는 추상화 계층 위에 구축된 몇 가지 복잡한 구성 요소(예: 정교화 로직과 솔버가 사용하는 탐색 그래프 메커니즘)도 포함되어 있습니다.
설계 개념
rustc_next_trait_solver는 rustc_type_ir에 정의된 추상 인터페이스에만 의존하도록 설계되었습니다. 이를 지원하기 위해 rustc_type_ir의 타입 시스템 트레이트는 솔버가 필요로 하는 모든 인터페이스를 노출해야 합니다. 새 추론 타입 변수 생성(rustc, rust-analyzer)이 그 예입니다. 컴파일러별 표현이 필요하지 않은 항목의 경우, rustc_type_ir는 이를 이러한 트레이트로 매개변수화된 구조체나 열거형으로 직접 정의합니다. TraitRef가 그 예입니다.
다음은 rustc_type_ir 크레이트의 주요 항목들입니다.
trait Interner
이 설계의 핵심 트레이트는 Interner이며, rustc와 rust-analyzer 양쪽의 구현별 세부 사항을 모두 지정합니다. 이 트레이트의 핵심 책임은 다음과 같습니다.
- 연관 타입을 통해 구현이 사용하는 구체 타입을 지정합니다. 이 타입들은 각 컴파일러 프런트엔드가 공유 IR을 인스턴스화하는 방식의 근간을 이룹니다.
- 솔버에 필요한 컨텍스트를 제공합니다(예: 언어 항목 쿼리, 트레이트에 대한 모든 블랭킷 impl 열거).
- 그리고 포매팅과 트레이싱을 위해
IrPrint를 구현해야 합니다.
실제로 이러한IrPrintimpl은 rustc나 rust-analyzer 내부의 기존 포매팅 로직으로 연결되기만 합니다.
rustc에서는 TyCtxt가 Interner를 구현합니다. TyCtxt는 rustc의 쿼리 메서드를 노출하며, 필요한 Interner 트레이트 메서드는 해당 쿼리를 호출하는 방식으로 구현됩니다. rust-analyzer에서는 이를 구현하는 타입의 이름이 DbInterner이며(대부분의 인터닝을 salsa 데이터베이스를 통해 수행하기 때문입니다), 그 메서드 대부분은 rustc 쿼리가 아닌 salsa 쿼리를 기반으로 동작합니다.
mod inherent
rustc_type_ir에서 주목할 만한 또 다른 항목은 inherent 모듈입니다. 이 모듈은 Ty나 GenericArg와 같은 컴파일러별 타입에 존재하는 메서드에 대응하는 고유 메서드의 전방 정의를 트레이트 형태로 제공합니다.
이러한 정의 덕분에 제네릭 크레이트(예: rustc_next_trait_solver)는 rustc와 rust-analyzer에서 서로 다르게 구현된 메서드를 호출할 수 있습니다.
제네릭 크레이트의 코드는 다음과 같이 이러한 정의를 임포트해야 합니다.
#![allow(unused)]
fn main() {
use inherent::*;
}
이러한 전방 정의는 구체 구현 자체 내부에서는 절대 사용해서는 안 됩니다. mod inherent의 트레이트를 구현하는 크레이트는 구체 타입의 이름을 지정할 수 있게 되면 해당 타입의 실제 고유 메서드를 호출해야 합니다.
이러한 트레이트에 대한 rustc의 구현은 rustc_middle::ty::inherent 모듈에서 찾을 수 있습니다. rust-analyzer의 경우, 해당 구현은 hir_ty::next_solver::region과 같이 hir_ty::next_solver 아래의 여러 모듈에 흩어져 있습니다.
trait InferCtxtLike와 trait SolverDelegate
이 두 트레이트는 rustc에서 InferCtxt가 맡는 역할에 대응합니다.
InferCtxtLike는 일관성(coherence) 제약(고아 규칙) 때문에 rustc_infer에 정의되어야 합니다. 그 결과, 이 트레이트는 rustc_trait_selection에 있는 기능을 제공할 수 없습니다. 대신 트레이트 해결 로직에 의존하는 동작은 별도의 트레이트인 SolverDelegate로 추상화됩니다. rustc에서 이 트레이트의 구현체는 rustc_trait_selection에 있는 InferCtxt를 감싸는 단순한 newtype 구조체입니다.
(rust-analyzer에서도 자체 InferCtxt를 감싸는 newtype 래퍼에 대해 구현되어 있습니다. 이는 주로 rustc의 구조를 그대로 따르기 위한 것이며, 솔버 관련 로직이 이미 모두 hir-ty 크레이트에 있으므로 엄밀히 말해 필요한 것은 아닙니다.)
장기적으로 이상적인 설계는 현재 SolverDelegate를 통해 표현되는 모든 로직을 rustc_next_trait_solver로 옮기고, 필요한 핵심 연산은 InferCtxtLike에 직접 추가하는 것입니다. 이렇게 하면 솔버 동작의 더 많은 부분이 공유 솔버 크레이트 내부에 온전히 자리 잡을 수 있습니다.
rustc_type_ir::search_graph::{Cx, Delegate}
추상화 트레이트인 Cx와 Delegate는 이미 rustc_next_trait_solver 자체 내에 구현되어 있습니다. 따라서 공유 크레이트의 사용자인 rustc와 rust-analyzer는 별도의 구현을 제공할 필요가 없습니다.
이러한 트레이트는 주로 전체 트레이트 솔버와 독립적으로 탐색 그래프를 퍼징(fuzzing)할 수 있도록 지원하기 위해 존재합니다. 이 인프라는 외부 퍼징 프로젝트인 https://github.com/lcnr/search_graph_fuzz에서 사용됩니다.
파생 트레이트
trait TypeVisitable과TypeVisitable_Generictrait TypeFoldable과TypeFoldable_Generictrait Lift와Lift_Generictrait GenericTypeVisitable
이 트레이트들은 rustc_type_ir에서 많이 사용됩니다. 관련 매크로는 주로 Lift, TypeFoldable, TypeVisitable, GenericTypeVisitable을 구현할 때 매크로가 없으면 작성해야 하는 보일러플레이트를 줄이기 위해 존재합니다.
trait TypeVisitable과 TypeVisitable_Generic
이 트레이트는 구조체나 열거형이 visit_with(...) 메서드를 구현하도록 요구하며, 이 메서드는 다시 TypeVisitor에 제어를 넘깁니다. 이는 여기에서 자세히 확인할 수 있습니다.
TypeVisitable_Generic과 GenericTypeVisitable은 이름 때문에 겉보기에는 비슷해 보이지만, 서로 다른 두 방문 시스템을 구현합니다.
TypeVisitable_Generic은 일반적인TypeVisitable트레이트를Interner에 대해 제네릭하게 derive한다는 뜻입니다.GenericTypeVisitable은 rust-analyzer처럼 nightly가 아닌 환경의 사용자가 쓰는 별도의GenericTypeVisitable트레이트를 derive한다는 뜻입니다.
TypeVisitable_Generic
값의 필드를 선언 순서대로 방문하며, 각 필드를 그 필드 자체의 TypeVisitable<I> 구현에 위임합니다. 방문자가 잔여(residual) 결과를 반환하면 순회가 일찍 중단될 수 있습니다.
필드를 무시하려면 #[type_visitable(ignore)]를 사용하십시오. 해당 필드는 순회에 포함되지 않으며 TypeVisitable<I>를 구현할 필요도 없습니다. 이는 해당 필드를 순회할 필요가 없는 경우에만 사용해야 합니다.
trait TypeFoldable과 TypeFoldable_Generic
이 트레이트는 타입을 내포해야 하는 것들이 구현합니다. 이 개념은 여기에서 자세히 다루며, 소스에서 따라가 볼 수 있습니다.
TypeFoldable_Generic은 구조체나 열거형에 대해 rustc_type_ir::TypeFoldable<I>를 derive합니다.
값을 소비하고, 그 필드를 폴딩한 뒤 동일한 구조체 또는 열거형 배리언트를 재구성합니다. 실패할 수 있는 폴딩 메서드와 실패하지 않는 폴딩 메서드를 모두 생성합니다.
값이 변경 없이 보존되어야 하는 필드에는 #[type_foldable(identity)]를 사용하십시오. 매크로는 해당 필드를 폴더에 전달하지 않고 재구성된 값으로 직접 이동시킵니다. 따라서 해당 필드의 타입은 TypeFoldable<I>를 구현할 필요가 없습니다.
열거형의 경우, 생성된 match에는 variant마다 하나의 재구성 arm이 포함됩니다.
trait Lift와 Lift_Generic
이 트레이트에는 lift_to_interner(...) 메서드가 있습니다. 이름에서 알 수 있듯이, 이 메서드는 무언가를 인터너로 ‘들어 올려야(lift)’ 합니다. 인터너에 대해 더 알아보려면 여기를 참고하시고, 소스는 여기를 참고하십시오.
Lift_Generic 매크로는 구조체나 열거형에 대해 Lift를 derive하며, 쉽게 알아차리기 어려운 고려 사항이 세 가지 있습니다.
- 제네릭 매개변수
I와J는 예약되어 있으며,I는I: Interner를,J는 값을 들어 올릴 대상 인터너를 나타냅니다. PhantomData는 자동으로 처리되어 새PhantomData가 생성되지만,use std::marker::PhantomData;를 통해 파일에 반드시 포함해야 합니다. 구조체의 필드에std::marker::PhantomData를 직접 사용할 수는 없습니다.- 이 바운드는
LiftInto에 대한where절이 아니라Interner트레이트의 연관 타입 바운드로 의도적으로 작성되어 있습니다. 따라서I: LiftInto<J>만 주어져도 Rust는 다음과 같은 바운드를 암시된 것으로 취급할 수 있습니다.
#![allow(unused)]
fn main() {
I::Ty: Lift<J, Lifted = J::Ty>
I::Const: Lift<J, Lifted = J::Const>
}
이를 통해 Lift_Generic은 I: LiftInto<J> 바운드를 내보내면서도 I::Ty, I::Const 및 그 밖에 선언된 연관 타입의 필드에 대해 lift_to_interner를 호출할 수 있습니다. 또한 derive가 I::Assoc를 J::Assoc로 다시 쓴 뒤에 기대되는 대상 필드 타입을 각 호출이 생성하도록 보장합니다.
declare_lift_into!가 없다면 derive는 모든 필드가 사용하는 인터너 연관 타입마다 별도의 바운드를 생성해야 합니다. 새로운 Interner 연관 타입이 Lift_Generic과 함께 동작해야 한다면, 해당 타입에는 적절한 Lift 구현이 필요하며 일반적으로 declare_lift_into! 호출에도 포함되어야 합니다.
리프트할 수 없는 u32 같은 기본 타입 필드처럼 무시하고 싶은 필드가 있다면 #[lift(ignore)]로 해당 필드를 건너뛸 수 있습니다.
trait GenericTypeVisitable
이것은 오로지 rust-analyzer에서만 사용하는, 별도의 더 일반적인 순회 트레이트입니다. 방문자 타입은 메서드의 매개변수가 아니라 트레이트의 매개변수이며, 방문은 결과를 반환하지도 않고 단락(short-circuiting)도 지원하지 않습니다.
따라서 구조체나 열거형은 TypeVisitable_Generic과 GenericTypeVisitable을 모두 derive할 수 있습니다.
ignore 속성은 의도적으로 제공하지 않습니다. 순회는 모든 필드를 방문해야 합니다. 이는 rust-analyzer가 인턴된 타입을 추적하고 가비지 컬렉션할 때 이 순회를 사용하기 위한 건전성 요구 사항입니다.
매크로 크레이트의 nightly 기능이 활성화되면 derive 매크로는 등록된 상태로 남지만 아무 토큰도 내보내지 않습니다. GenericTypeVisitable 트레이트와 그 순회 모듈 역시 rustc_type_ir의 nightly 구성에서는 제외되며, non-nightly 구성에만 존재합니다.
rust-analyzer 지원을 위한 장기 계획
일반적으로 우리는 rustc의 성능이나 유지 보수성을 크게 해치지 않는 한, 이러한 공유 크레이트에서 rust-analyzer를 rustc만큼 잘 지원하는 것을 목표로 합니다. (예: #145377, #146111, #146182, #147723)
rust-analyzer는 stable 툴체인으로 빌드되므로, nightly 전용 기능이 필요한 공유 크레이트는 해당 코드를 nightly 기능 플래그 뒤에 두어야 합니다.
앞으로 우리는 더 많은 공유 로직을 rustc_type_ir로 끌어올릴 계획입니다. rustc와 rust-analyzer 사이에는 ObligationCtxt(rustc, rust-analyzer)와 타입 강제 변환 로직(rustc, rust-analyzer)처럼 여전히 중복된 구현이 있으며, 우리는 이를 점차 통합하고자 합니다.
CoerceUnsized
CoerceUnsized는 주로 데이터 컨테이너와 관련됩니다. 구조체(일반적으로 스마트 포인터)가 CoerceUnsized를 구현한다는 것은 그 구조체가 가리키는 데이터가 언사이즈(unsize)된다는 뜻입니다.
CoerceUnsized를 구현하는 타입에는 다음과 같은 것들이 있습니다.
&TArc<T>Box<T>
이 트레이트는 (궁극적으로) 사용자가 작성한 스마트 포인터가 구현하도록 의도되었으며, 어떤 타입이 CoerceUnsized를 구현할 수 있는 조건에 관한 규칙은 이 트레이트의 문서에 설명되어 있습니다.
Unsize
이와 대조적으로, Unsize 트레이트는 언사이즈될 수 있는 실제 타입과 관련됩니다.
Unsize는 컴파일러(즉, 코드 생성)에 타입을 어떻게 언사이즈할지 알려 주지 않고 언사이즈가 허용되는지 여부만 알려 주므로, 사용자가 이를 구현하도록 의도된 경우는 전혀 없습니다. 이는 타입이 어떻게 표현되고 언사이즈되는지를 이해해야 하는 코드 생성과 상당히 밀접하게 연결되어 있습니다.
기본 언사이징 구현
다음에 대해 내장 구현이 제공됩니다.
T: Trait일 때(그리고T: Sized + 'a이며Trait가 dyn 호환1일 때)T->dyn Trait + 'a[T; N]->[T]
구조적 구현
구조적이라고 볼 수 있는 Unsize 구현이 하나 있습니다.
TailField<Pi, .., Pj>: Unsize<Ui, .. Uj>가 주어졌을 때Struct<.., Pi, .., Pj, ..>: Unsize<Struct<.., Ui, .., Uj, ..>>이며, 이는 구조체의 마지막 필드가 제네릭 매개변수Pi, ..,Pj(연속적일 필요는 없습니다)를 언급하는 유일한 필드인 경우 해당 필드를 언사이즈할 수 있게 합니다.
구조체 언사이징 규칙은 둘 이상의 매개변수를 변경하도록(반드시 언사이즈되는 것은 아닙니다) 허용할 수 있으므로 다소 복잡하며, 구조체의 마지막 필드를 기준으로 설명하는 것이 가장 좋습니다.
(튜플 언사이징은 이전에 기능 게이트 unsized_tuple_coercion 뒤에 구현되어 있었으나, 이 구현은 #137728에서 제거되었습니다.)
업캐스팅 구현
내부적으로 “업캐스팅“이라고 불리는 것은 다음 두 가지입니다.
- 진정한 업캐스팅
dyn SubTrait->dyn SuperTrait(아래와 같이 자동 트레이트를 제거하고 라이프타임을 조정하는 것도 허용합니다). - 주(principal) 트레이트를 변경하지 않고2 dyn 트레이트의 자동 트레이트를 제거하고 라이프타임을 조정하는 것:
AutoTraits⊇NewAutoTraits이고'a: 'b일 때dyn Trait + AutoTraits... + 'a->dyn Trait + NewAutoTraits... + 'b.
(1.)은 dyn 트레이트의 vtable 조정을 포함하는 반면 (2.)는 아무 작업도 하지 않으므로, 이 둘은 서로 다른 연산처럼 보일 수 있습니다. 그러나 타입 시스템에서는 이 둘이 거의 같은 코드로 처리됩니다.
이 Unsize 내장 구현은 가장 복잡하며, 특히 연관 타입의 복잡성을 지원하기 위해 재작업된 이후로 더욱 그렇습니다.
구체적으로, 업캐스팅 알고리즘은 다음 과정을 포함합니다. 소스 dyn 트레이트의 주 트레이트(principal)의 각 슈퍼트레이트(자기 자신 포함)에 대해…
- 슈퍼트레이트 참조를 대상의 주 트레이트와 통합합니다(항상 진짜 슈퍼트레이트로만 업캐스팅하고, 절대 impl을 통해 업캐스팅하지 않도록 보장합니다).
- 대상의 모든 자동 트레이트에 대해 해당 트레이트가 원본에 존재하는지 확인합니다(이를 통해 자동 트레이트를 제거할 수는 있지만 새로 얻을 수는 없습니다).
- 대상의 모든 프로젝션에 대해 원본의 단일 프로젝션과 단일화되는지 확인합니다(
trait Sub: Sup<.., A = i32> + Sup<.., A = u32>가 주어진 경우 둘 이상이 있을 수 있기 때문입니다).
구체적으로 (3.)은 프로젝션 바운드의 선택이 불필요하게 추론을 유도하지 않도록 막습니다. 다만 선택이 모호하지 않을 때는 추론을 유도할 수 있습니다.
Trait 바운드와 Projection 바운드를 분리해 두는 이유
T: Foo<AssocA = u32, AssocB = i32> where 바운드가 주어지면, 현재는 이를 Trait(Foo<T>) 바운드와 별도의 Projection(<T as Foo>::AssocA, u32) 및 Projection(<T as Foo>::AssocB, i32) 바운드로 로워링합니다. 왜 이를 대신 단일한 Trait(Foo[T], [AssocA = u32, AssocB = u32] 바운드로 표현하지 않습니까?
Projection 바운드를 증명하는 방식은 대응하는 Trait 바운드를 증명하는 데 직접 의존합니다: 기존 솔버 새 솔버.
트레이트가 구현되어 있는지 검사하고 그 연관 타입(또는 그것을 계산하는 방법)을 반환하는 단일 구현만 두는 편이 더 합리적일 것처럼 느껴집니다.
안타깝게도 이는 상당히 어려운데, 정규화에 사용하는 후보가 대응하는 트레이트 바운드에 사용하는 후보와 다를 수 있기 때문입니다. 별칭 바운드 vs where 바운드 및 전역 where 바운드 vs impl을 참조하십시오.
그렇게 할 수 없는 미묘한 이유가 몇 가지 더 있습니다. 가장 어처구니없는 이유는 경직된(rigid) 별칭에 관한 것으로, 이를 정규화하려 할 때 트레이트 바운드를 증명하는 과정에서 생기는 라이프타임 제약을 전혀 고려하지 않는다는 점입니다. 이는 바인더에 대한 가정이 부족하기 때문에 필요한 것이며 - https://github.com/rust-lang/trait-system-refactor-initiative/issues/177 - 장기적으로는 수정되어야 합니다.
별개의 문제로, 현재 Trait 목표에 대해 또는 가려진(shadowed) Projection 후보 안에서 연관 타입의 type_of를 가져오면 RPITIT에 대해 쿼리 순환이 발생할 수 있습니다. https://github.com/rust-lang/trait-system-refactor-initiative/issues/185 를 참조하십시오.
일부 내장 impl의 경우 후보들 사이에 미세한 차이도 있는데, 이러한 차이는 모두 대체로 바람직하지 않아 보이며, 저는 이를 여기서 통합된 접근 방식을 사용했다면 수정되었을 버그라고 봅니다.
마지막으로, 이러한 분리가 없으면 where 절을 로워링하는 작업이 더 번거로워집니다. 현재 시스템에서는 중복된 where 절이 있어도 문제가 되지 않으며, 이는 상위 트레이트 바운드를 전개(elaborate)할 때 쉽게 발생할 수 있습니다. 그렇게 되면 모든 연관 타입 제약을 반드시 병합해야 합니다. 예를 들면 다음과 같습니다.
#![allow(unused)]
fn main() {
trait Super {
type A;
type B;
}
trait Trait: Super<A = i32> {}
// how to elaborate Trait<B = u32>
}
혹은 더 나쁜 경우도 있습니다.
#![allow(unused)]
fn main() {
trait Super<'a> {
type A;
type B;
}
trait Trait<'a>: Super<'a, A = i32> {}
// how to elaborate
// T: Trait<'a> + for<'b> Super<'b, B = u32>
}
적격성
적격성이란 무엇입니까?
“적격(well-formed)“이란 “올바르게 구성된“이라는 뜻입니다1. 어떤 것의 구조가 규칙을 따를 때 그것은 적격합니다. Rust 컴파일러에서 이 용어를 사용할 때 우리가 관심을 두는 것은 일종의 내부 일관성을 확립하는 일입니다.
Rust의 적격성
어떤 것이 적격한지 확인하는 것을 “적격성 검사“를 수행한다고 합니다.
Rust 컴파일러에는 서로 다른 두 가지 형태의 적격성 검사가 있습니다.
- 타입 수준 항2 3 적격성 검사.
- “항 적격성” 또는 “항 적격성 검사“라고도 합니다.
- 별개의 분석 단계가 아니며, 분석 전반에 걸쳐 수행됩니다.
- 아이템4 적격성 검사(item-wfck).
- “Item-wfck“는 결국 항이 적격할 것을 요구하는 경우가 많지만, 일부 영역은 건너뜁니다.
- 내부의 “항“이 (잘못하여) 먼저 정규화될 수 있습니다.
- (여러 곳에서 수행되는) “항 적격성“보다 컴파일러의 한 단계로서 더 일관성이 있습니다.
참고: 적격성이 아닌 것.
타입 수준 항의 적격성
항 적격성 검사는 항이 적격하기 위해 참이어야 하는 것들의 목록을 만드는 것으로 시작합니다. 우리는 이것들을 “의무(Obligations)“라고 부릅니다5.
타입 수준 항은 연관된 의무가 트레이트 솔버에 의해 충족될 때 적격한 것으로 간주됩니다.
적격성을 위한 의무
구체적인 의무로는 String: Clone, A: usize, <T as Iterator>::Item: Debug 같은 것이 있습니다.
이 페이지에서는 의무와 항/아이템 사이의 구분을 다음과 같이 나타냅니다.
<terms or items>
---
<obligations>
다음은 적격한 타입 수준 항의 예입니다.
Vec<String>
---
// Obligations to fulfill
Vec<T> where T: Sized
// Trait solver says `String: Sized` is true, so this is well-formed.
Vec<String> where String: Sized
Vec<String>에 대한 의무를 계산하면 Vec<T>가 T: Sized라는 의무를 생성한다는 것을 알게 됩니다. Vec<String>에서 T를 String으로 치환하므로 String: Sized라는 의무를 얻게 되며, 트레이트 솔버는 이 의무가 충족된다고 판단합니다.
다음은 적격하지 않습니다.
Vec<str>
---
// Obligations to fulfill
Vec<T> where T: Sized
// Trait solver says `str: Sized` is not true, so this is not well-formed.
Vec<str> where str: Sized
위의 예는 앞에서와 마찬가지로 T: Sized라는 의무를 계산하지만, Vec<str> 인스턴스에서 T를 str로 치환하므로 str: sized라는 의무를 얻게 됩니다. 트레이트 솔버는 이 의무가 충족되지 않는다고 판단합니다.
의무 결정하기
컴파일러에서 항의 의무는 항 적격성 모듈의 obligations 함수를 통해 찾습니다.
기타 의무
의무에는 트레이트 바운드와 상수 제네릭 바운드 외에도 여러 종류가 있지만, 지금까지는 항의 “적격성 검사“를 수행할 때 관심을 두는 이 특정 의무들만 언급했습니다. 의무의 전체 목록은 PredicateKind와 ClauseKind를 참고하십시오.
정규화는 (아직) 필요하지 않습니다
정규화는 타입 별칭을 그 기반 타입으로 해석하는 과정입니다.
타입 별칭은 where 절이 충족되면 적격한 것으로 간주됩니다. 기반 타입은 대부분의 정의 위치와 인스턴스화 위치에서 적격성 검사를 거치지만, 예외도 있습니다.
상수 제네릭 인자
항 적격성은 상수 제네릭 항6의 “타입 검사” 의무를 얻는 역할을 담당합니다. 다음과 같은 상수 제네릭 사용 예를 살펴봅시다:
fn use_const_generics<const U: usize>() { /* ... */ }
// call site
use_const_generics::<6>();
---
// call site wfck obligations
const 6: usize
적격성 검사 중에 호출 위치는 6: usize라는 의무를 제공합니다. 트레이트 솔버는 이름이 암시하는 것보다 더 많은 역할을 맡고 있으므로, 이 의무는 다른 트레이트 형태의 의무와 마찬가지로 트레이트 솔버에 전달됩니다.
아이템의 적격성
아이템은 일반적으로 말해 “정의되는 것들“입니다. 아이템 적격성 검사(item-wfck)는 타입과 함수, 메서드, 그리고 트레이트의 정의/구현에 대해 시그니처 수준에서 이루어집니다.
// The `Vec<str>` is checked during item wfck
fn foo(_: Vec<str>) {
// The `Vec<[u8]>` is not handled by item wfck as it's not in the signature
let _: Vec<[u8]>
}
---
Vec<str>: Sized // Generated
Vec<[u8]>: Sized // Not done at item-wfck. Done elsewhere.
item-wfck는 내부 타입 수준 항의 의무를 수집하여 트레이트 솔버에 전달하는 것 외에도 더 많은 역할을 맡고 있습니다. 여기서 이 역할을 모두 다루지는 않지만, item-wfck 모듈의 개별 check_* 함수에서 찾아볼 수 있습니다.
전역 바운드와 자명한 바운드
트레이트 바운드는 흔한 의무입니다. 전역(Global) 트레이트 바운드와 자명한(Trivial) 트레이트 바운드는 참인지 거짓인지 판단하기에 충분한 정보를 이미 가지고 있는 트레이트 바운드의 종류입니다. item-wfck는 이러한 바운드를 찾아 검사하는 역할을 담당합니다.
- 전역 바운드는 기존 솔버에서 정규화 이후에 제네릭 매개변수(
<T>나'a등)나 바운드 변수(for<'b>등)를 포함하지 않는 바운드입니다. - 자명한 바운드는 적격인지 여부를 판단하는 데 추가적인 정규화가 필요하지 않은 바운드입니다.
다음 함수 정의를 살펴보십시오.
fn apartment_complex<T>(block: T, name: String) where String: Clone { /* ... */ }
---
String: Clone // Trivial & Global bound! There's no aliases to resolve.
// There could be bligations on T but we don't care about them here.
이 정의는 전역(제네릭 매개변수 없음)이면서 자명한(적격성 검사에 정규화가 필요하지 않음) 트레이트 바운드 의무 String: Clone을 생성합니다. 트레이트 솔버가 String: Clone의 적격성을 판단하는 데 추가적인 정보를 제공받을 필요는 없습니다.
거짓인 자명한 바운드는 단순히 성립하지 않는 자명한 바운드입니다. 다음은 기본적인 예입니다.
fn apartment_simple<T>(block: T, name: String) where String: Copy { /* ... */ }
---
String: Copy // Trivial bound again, but this one is false!
여기서는 String이 Copy가 아니므로 성립하지 않는 자명한 바운드가 있습니다.
자명한 바운드가 항상 전역 바운드인 것은 아닙니다
자명한 바운드는 전역 바운드의 부분집합이 아닙니다. 전역이 아닌 자명한 바운드의 예로는 for<'a> String: Clone(자명하게 참이며 바운드 변수를 가짐)이나 &'a str: Copy(자명하게 거짓이며 제네릭 매개변수를 가짐)가 있습니다.
item-wfck와 자명한/전역 바운드
항목이 적격한지 검사할 때 자명하게 거짓인 전역 바운드가 없는지 검사합니다.
적격성 검사를 완전히 수행하지 않는 경우
적격성 검사는 타입 검사의 일관된 하나의 “단계“가 아닙니다. 적격성 검사가 수행되는 영역은 많으며, 현재 수행할 수 있는 분석의 종류에 한계가 있어 적격성 검사를 건너뛰는 영역도 일부 있습니다. 이상적으로는 적격성 검사를 절대 건너뛰거나 미루지 않아야 합니다.
(때로는) 정규화가 필요합니다
항목의 항이 적격성 검사를 거치기 전에 해당 항목의 정규화가 일어나는 곳이 있습니다. 이렇게 하면 일부 항이 항 적격성 검사를 완전히 우회할 수 있으므로 문제가 있는 것으로 간주됩니다.
트레이트 객체
트레이트 객체가 적격한지 판단할 때 해당 트레이트 객체의 where 절이 적격할 것을 요구하지 않습니다. 이러한 where 절은 트레이트 객체로 강제 변환할 때 증명되지만, 이는 여전히 적격성 검사의 허점으로 남아 있습니다.
예를 들어, 다음 코드는 구체 타입으로부터 트레이트 객체를 생성하는 지점이 없기 때문에 컴파일됩니다:
trait Trait
where
for<'a> [u8]: Sized {}
fn foo(_: &dyn Trait) {}
---
// This doesn't end up being generated, because it happens within a trait object.
[u8]: Sized
위 코드는 [u8]: Sized 때문에 컴파일되지 않아야 하지만, 이는 실제로 사용될 때까지 검사되지 않습니다:
trait Trait
where
for<'a> [u8]: Sized {}
fn foo(_: &dyn Trait) {}
// We still need to specify the bound here, otherwise `[u8]: Sized` _is_
// checked as an obligation.
impl Trait for u8 where for<'a> [u8]: Sized {}
fn main() {
// No matter what we do, this boundary between concrete type and trait
// object will produce the obligation `[u8]: Sized`, which will fail when
// handed over to the trait solver.
let object: Box<dyn Trait> = Box::new(42u8);
foo(&object);
}
이 예외는 트레이트 객체 안의 상수 제네릭 인자에는 적용되지 않습니다:
trait Trait<const N: usize> {}
fn foo<const B: bool>(_: &dyn Trait<B>) {}
---
const N: usize
const B: bool
N = B // Substitution
const B: usize + bool
앞서 제시한 예제와 달리 위 코드는 컴파일되지 않습니다. 상수 제네릭 인자에 관해서는 여기서 어느 정도 적격성 검사를 수행합니다.
바인더 / 고차 타입
바인더 / 고차 타입은 항에 대해 수행하는 적격성 검사의 양을 줄이며, 적격성 검사를 바운드가 인스턴스화되는 시점으로 미룹니다:
let _: for<'a> fn(Vec<[&'a ()]>);
---
// This doesn't end up being generated, because it happens within a HRB
[&'a ()]: Sized // slices aren't sized, this would fail!
구체적으로, 바인더(for<'a>)의 변수가 관련된 의무(obligation)는 바인더가 인스턴스화될 때에만 검사됩니다. for<'a> 아래에서도 일부는 여전히 검사되지만, 많은 것을 여전히 건너뜁니다.
이 동작을 둘러싸고 많은 건전성 문제가 있습니다. 참고: #25860, #84591.
다음을 살펴봅시다:
for<'a, 'b> fn(&'a &'b ())
위의 HRB는 완전히 별개인 두 라이프타임이 아니라 'b: 'a(라이프타임 바운드)를 함의합니다. 이는 정상적인 라이프타임 동작이지만, 적격성 검사 중에는 이 바운드가 일반적으로 참임을 증명할 수 없으므로7 이를 건너뜁니다.
자유 타입 별칭
자유 타입 별칭8의 우변은 정의 위치에서 적격한지 완전히 검사되지 않으며, 우변에 있는 상수 제네릭 인자의 타입만 검사됩니다.
이 글을 작성하는 시점에서 다음 자유 타입 별칭은 타입 검사를 통과합니다:
type WorksButShouldNot = Vec<str>;
---
// This should fail! But we skip the RHS of free type aliases
str: Sized // Not generated
T: Sized와 str: Sized가 모두 Vec<T>에 의해 함의되므로 이 코드는 동작하지 않아야 합니다. 자유 타입 별칭의 우변은 사용되기 전까지 적격성 검사를 거치지 않기 때문에 이 코드는 item-wfck를 “통과“합니다. 이 특정한 경우에는 item-wfck가 사용될 때까지 미뤄집니다.
상수 제네릭의 경우, 자유 타입 별칭의 정의 위치에서는 여전히 소량의 적격성 검사를 수행합니다. 이는 where 바운드 같은 것을 건너뛸 때 상수 제네릭 적격성 검사를 특별히 처리하는 현재 방식과 일치합니다.
즉, 다음 코드는 위 예제와 비슷한 형태임에도 불구하고 마땅히 실패해야 하는 대로 실패합니다:
pub struct Consty<const A: bool>;
type Alias = Consty<42>;
---
// This *is* generated as an obligation, so this (correctly) fails.
42: bool // This is generated!
“well-formed”(적격)입니까, “wellformed“입니까?
논리학 문헌과 일관되도록 “wellformed“보다 “well-formed”(적격)를 사용하십시오. 이 용어는 개발 가이드 / 문서의 다른 부분에서 WF로 줄여 쓰기도 합니다.
비공식적 용법
“적격성(well-formedness)“은 형식 구조의 올바름과 관련된 일반적인 표현이므로, 기여자들이 대화 중에 어떤 것을 “적격(well-formed)“이라고 부르더라도 반드시 여기서 다루는 의미를 뜻하는 것은 아닙니다. 이것이 반드시 잘못된 것은 아니지만 주의해야 합니다.
적격성이 아닌 것
적격성 검사는 “매개변수 개수” 검사나 “매개변수 타입” 검사가 아닙니다9. 적격성 검사라는 용어도, item-wfck라는 용어도 매개변수가 2개인 타입에 1개 또는 3개의 타입이 적용되었는지(기본값이 없다고 가정할 때), 또는 상수 제네릭 매개변수에 타입이 적용되었는지와는 관련이 없습니다. 이러한 종류의 문제는 wfck가 아니라 HIR-ty 로워링10 중에 처리됩니다.
적격성은 라이프타임을 검사하거나 검증하지 않으며, 이는 MIR에서 처리됩니다.
Rust 컴파일러에서의 적격성은 논리학에서처럼 “올바른 구문“에 해당하지 않습니다. 이 용어는 수학적 맥락에서 “주어진 규칙 집합을 따른다“는 의미로 두루 쓰여 온 역사가 있습니다. Rust에서 원래의 용법은 원래의 프로젝션과 적격성에 대한 명확화 RFC 같은 곳에서 볼 수 있듯이, 타입에 대한 바운드와 관련하여 “이것은 내부적으로 일관성이 있다“는 의미에 더 가까웠습니다.
-
언어학에서는 “문법적으로 올바름”, 논리학에서는 “구문적으로 올바름“이며, 수학자들의 일상적인 용법에서는 좀 더 일반적인 “이 영역에 대해 정한 규칙을 따른다“로 읽을 수 있습니다. ↩
-
일반적인 의미의 타입 표현식과 하위 표현식을 가리키며, Rust 컴파일러의 특정 구조체나 열거형을 뜻하지 않습니다. 용어집을 참고하십시오. ↩
-
이 페이지의 일부 영역에서는 “항(Terms)“으로 줄여 부릅니다. ↩
-
이것들은 문서에서 의무(Obligations), 요구 사항(Requirements), 또는 제약(Constraints)이라고 불립니다. 타입의 접미사 및 관련 함수의 이름과 일치하므로 “의무(obligations)“라는 용어를 선호합니다. 향후에는 새 솔버의 용어인 “목표(Goal)“로 대체될 수 있습니다. ↩
-
#checking-types-of-const-arguments ↩
-
대신, 이 바운드는 라이프타임이 인스턴스화될 때 “MIR borrowck” 중에 검사됩니다. ↩
-
어떤 것과도 연관되지 않은 타입 별칭, 즉 모듈 수준의
type Alias = Vec<u8>;입니다. ↩ -
Haskell 같은 언어에서 볼 수 있는 “카인드 검사(kind checking)“라고도 합니다. ↩
-
https://doc.rust-lang.org/nightly/nightly-rustc/rustc_hir_analysis/hir_ty_lowering/index.html ↩
타입 및 라이프타임 매개변수의 변성
변성에 대한 더 일반적인 배경 지식은 배경 부록을 참고하십시오.
타입 검사 과정에서는 타입 및 라이프타임 매개변수의 변성을 추론해야 합니다. 이 알고리즘은 PLDI’11에 발표된 Altidor 외 저자의 논문 “Taming the Wildcards: Combining Definition- and Use-Site Variance”(와일드카드 길들이기: 정의 지점 변성과 사용 지점 변성의 결합)의 4절에서 가져온 것이며, 이하에서는 이 논문을 ’해당 논문’이라고 부릅니다.
이 추론은 코드 내에서 타입이 사용되는 방식을 고려하지 않도록 명시적으로 설계되었습니다. 타입 X에 정의된 타입 매개변수의 변성을 결정할 때는 타입 X의 정의와 그 타입이 참조하는 모든 타입의 정의만을 고려합니다.
변성은 구조체와 열거형 같은 데이터 타입에 있는 타입 매개변수에 대해서만 추론합니다. 이러한 경우에는 변성이 무엇을 의미하는지 비교적 간단하게 설명할 수 있습니다. 타입 매개변수 또는 라이프타임 매개변수의 변성은 A와 B(각각 'a와 'b)의 관계에 따라 T<A>가 T<B>(각각 T<'a>와 T<'b>)의 서브타입인지를 정의합니다.
트레이트, 함수, impl에 있는 타입 매개변수에 대해서는 변성을 추론하지 않습니다. 트레이트 매개변수의 변성은 실제로 의미가 있을 수 있지만(예전에는 이를 계산하기도 했습니다), 그 의미가 상당히 미묘하고 실제로는 그다지 유용하지 않아서 제거했습니다. 자세한 내용은 부록을 참고하십시오. 반면 함수/impl 매개변수의 변성은 의미가 없습니다. 이러한 매개변수는 인스턴스화된 후 잊히기 때문입니다. 즉, 타입이나 컴파일된 부산물에 남지 않습니다.
표기법
이 장 전체에서 논문의 표기법을 사용합니다.
+는 공변성입니다.-는 반공변성입니다.*는 쌍변성입니다.o는 불변성입니다.
알고리즘
기본 아이디어는 매우 간단합니다. 정의된 타입들을 순회하면서, 타입 매개변수 X가 사용될 때마다 X의 변성이 해당 사용 위치의 변성에 대해 유효해야 한다는 제약 조건을 누적합니다. 그런 다음 모든 제약 조건이 충족될 때까지 X의 변성을 반복적으로 정제합니다. 해는 항상 존재합니다. 극단적인 경우 모든 타입 매개변수를 불변으로 선언하면 모든 제약 조건이 충족되기 때문입니다.
간단한 예로 다음을 살펴보십시오.
enum Option<A> { Some(A), None }
enum OptionalFn<B> { Some(|B|), None }
enum OptionalMap<C> { Some(|C| -> C), None }
여기서는 다음과 같은 제약 조건을 생성합니다.
1. V(A) <= +
2. V(B) <= -
3. V(C) <= +
4. V(C) <= -
이 제약 조건들은 (1) A의 변성이 최대 공변이어야 하고, (2) B의 변성이 최대 반공변이어야 하며, (3, 4) C의 변성이 최대 공변이면서 반공변이어야 함을 나타냅니다. 이 모든 결과는 다음과 같이 정의된 변성 격자에 기반합니다.
* Top (bivariant)
- +
o Bottom (invariant)
이 격자에 따르면 V(A)=+, V(B)=-, V(C)=o라는 해가 최적의 해입니다. 모든 변수를 불변으로 선언하기만 하는 단순한 해가 항상 존재한다는 점에 유의하십시오.
고정점 반복(fixed-point iteration)이 왜 필요한지 궁금하실 수 있습니다. 그 이유는 사용 위치의 변성(variance) 자체가 다른 타입 매개변수의 변성에 대한 함수일 수 있기 때문입니다. 가장 일반적인 형태로 보면, 제약 조건은 다음과 같은 형태를 띱니다:
V(X) <= Term
Term := + | - | * | o | V(X) | Term x Term
여기서 V(X) 표기는 타입/영역 매개변수 X가 그것을 정의하는 클래스에 대해 갖는 변성을 나타냅니다. Term x Term은 논문에서 정의된 “변성 변환(variance transform)“을 나타냅니다:
타입 표현식
E에서 타입 변수X의 변성이V2이고 클래스C의 대응하는 타입 매개변수의 정의 위치 변성이V1이라면, 타입 표현식C<E>에서X의 변성은V3 = V1.xform(V2)입니다.
제약
where 절이 있는 구조체나 열거형이 있다고 합시다:
struct Foo<T: Bar> { ... }
Bar에 대한 T의 변성이 Foo에 대한 T의 변성에 영향을 주는지 궁금하실 수 있습니다. 저는 그렇지 않다고 주장합니다. 그 이유는 다음과 같습니다. T가 Bar에 대해서는 불변(invariant)이지만 Foo에 대해서는 공변(covariant)이라고 가정해 봅시다. 그리고 X <: Y일 때 Foo<Y>로 업캐스트되는 Foo<X>가 있다고 합시다. 그런데 X : Bar는 성립하지만 Y : Bar는 성립하지 않습니다. 이 경우 업캐스트는 허용되지 않습니다. 하지만 그 이유는 변성 검사에 실패해서가 아니라 대상 타입 Foo<Y> 자체가 적격하지 않기 때문입니다. 기본적으로 변성을 고려하기 전에 관련된 모든 타입이 적격성을 갖춘다고 가정할 수 있습니다.
의존성 그래프 관리
변성은 크레이트 전체에 걸친 추론이므로, 주의하지 않으면 의존성 그래프가 상당히 복잡하게 뒤엉킬 수 있습니다. 이를 해결하기 위해 다음 두 개의 쿼리로 리팩터링합니다:
crate_variances는 현재 크레이트의 모든 항목에 대한 변성을 계산합니다.variances_of는 개별 항목의 변성에 접근합니다. 이 쿼리는crate_variances를 요청한 다음 관련 데이터를 추출하는 방식으로 동작합니다.
variances_of만 읽도록 제한하면, 코드는 해당 항목의 추론에만 의존하게 됩니다.
결국 이 구성은 레드-그린 알고리즘에 의존합니다. 특히 모든 변성 쿼리는 (crate_variances를 통해) 사실상 크레이트 전체의 모든 타입 정의에 의존하지만, 대부분의 변경은 변성 추론의 실제 결과를 바꾸지 않으므로 variances_of 쿼리는 재평가된 후 결국 그린으로 간주됩니다.
부록: 트레이트의 변성
앞서 언급했듯이, 예전에는 트레이트에 변성을 허용했습니다. 이 변성은 메서드 시그니처에 트레이트 타입 매개변수가 나타나는 방식을 기반으로 계산되었으며, 트레이트 객체에서 vtable의 호환성(그리고 트레이트 바운드에서의 “가상” vtable 또는 딕셔너리의 호환성)을 표현하는 데 사용되었습니다. 한 가지 복잡한 점은 연관 타입의 변성이 덜 명확하다는 것이었습니다. 연관 타입은 프로젝션되어 무수히 다양한 용도로 쓰일 수 있으므로, X<A>::Bar가 변하는 것을 언제 허용해도 안전한지(또는 그것이 정확히 무엇을 의미하는지조차) 분명하지 않습니다. 게다가 (아래에서 다루듯이) 연관 타입이 있는 트레이트의 입력은 전부 불변이어야 했기 때문에 적용 범위가 제한되었습니다. 마지막으로, 모든 트레이트 타입 매개변수가 변성을 갖도록 보장하는 데 필요했던 어노테이션(MarkerTrait, PhantomFn)은 혼란스럽고 번거로웠던 데 비해 얻는 이점은 거의 없었습니다.
역사적 참고 자료로서, 변성과 트레이트 매칭을 어떻게 해석할 수 있는지 보여 주는 글을 일부 남겨 두겠습니다.
변성과 객체 타입
구조체와 열거형의 경우와 마찬가지로, 두 객체 타입 &Trait<A>와 &Trait<B> 사이의 서브타이핑 관계는 A와 B의 관계를 바탕으로 결정할 수 있습니다. 객체 타입에서는 Self 타입 매개변수를 무시한다는 점에 유의하십시오. 이 매개변수는 알 수 없으며, 동적 디스패치의 특성상 항상 적절한 Self 타입을 기대하는 함수를 호출하게 됩니다. 그러나 다른 타입 매개변수는 주의해서 다뤄야 합니다. 그렇지 않으면 어떤 타입을 기대하는 함수를 다른 타입의 값으로 호출하게 될 수 있습니다.
무슨 뜻인지 알아보기 위해 다음과 같은 트레이트를 생각해 봅시다.
#![allow(unused)]
fn main() {
trait ConvertTo<A> {
fn convertTo(&self) -> A;
}
}
직관적으로, 하나의 객체 O=&ConvertTo<Object>와 또 다른 객체 S=&ConvertTo<String>가 있다면, String <: Object이므로 S <: O입니다(Java와 유사한 “string”(문자열) 타입과 “object”(객체) 타입을 가정합니다. 이는 서브타이핑을 설명할 때 제가 즐겨 드는 예시입니다). 실제 알고리즘은 (명시적) 타입 매개변수들을 각각의 변성을 고려하여 쌍별로 비교하는 것입니다. 여기서 타입 매개변수 A는 공변이므로(반환 위치에만 나타나므로) String <: Object를 요구하게 됩니다.
하지만 (암묵적) Self 타입 매개변수의 바인딩은 고려하지 않았다는 점을 눈치채셨을 것입니다. 사실 이 값은 알 수 없으므로, 고려하지 않는 편이 맞습니다. 이 매개변수를 무시할 수 있는 이유는 바로 호출이 일어나기 전까지는 그 값을 알 필요가 없기 때문이며, 호출 시점에는 (말씀하신 대로) 가상 디스패치의 동적 특성 덕분에, 메서드를 호출한 특정 객체에서 Self가 어떤 값에 바인딩되어 있든 실행되는 코드가 올바르게 동작합니다. 따라서 Self는 A와 다릅니다. 호출자는 메서드 convertTo()의 반환 타입을 알기 위해 A를 알아야 하기 때문입니다. (덧붙여, Self가 수신자 위치 이외의 곳에 나타나는 메서드는 객체를 통해 호출할 수 없도록 하는 규칙이 있습니다.)
트레이트 변성과 vtable 해석
그러나 트레이트는 객체에만 사용되는 것이 아닙니다. 주어진 impl이 주어진 트레이트 바운드를 만족하는지 판단할 때에도 사용됩니다. 상황을 설정하기 위해, 다음과 같은 함수가 있다고 가정해 봅시다.
fn convertAll<A,T:ConvertTo<A>>(v: &[T]) { ... }
이제 Object에 대한 ConvertTo 구현이 있다고 가정해 봅시다.
impl ConvertTo<i32> for Object { ... }
그리고 문자열 배열에 대해 convertAll을 호출하려고 합니다. 또한 어떤 이유에서인지 타입 매개변수 T의 값으로 String을 명시적으로 지정한다고 가정해 봅시다.
let mut vector = vec!["string", ...];
convertAll::<i32, String>(vector);
이것은 허용됩니까? 다르게 말하면, Object에 대한 impl을 타입 String에 적용할 수 있습니까? 답은 ’그렇다’이지만, 그 이유를 알려면 어떤 일이 일어나는지 풀어서 살펴봐야 합니다.
-
convertAll은 벡터의 항목 중 하나를 가리키는 포인터를 생성하며, 이 포인터의 타입은&String입니다 -
그런 다음 객체에 사용하도록 의도된
convertTo()의 impl을 호출합니다. 이 impl의 타입은fn(self: &Object) -> i32입니다.&String <: &Object이므로self에&String타입의 값을 제공해도 문제없습니다.
좋습니다. 직관적으로는 이것이 허용되기를 바라므로, 다시 변성으로 돌아가서 올바른 결과를 계산하고 있는지 확인해 봅시다. 먼저 “String,i32에 대한 impl이 기대되는 곳에서 Object,i32에 대한 impl을 사용할 수 있는가?“라는 질문을 어떻게 표현할지 알아내야 합니다.
타입 클래스의 딕셔너리 전달 방식 구현을 떠올려 보면 도움이 될 수 있습니다. 그 경우 convertAll()은 impl을 나타내는 암묵적 매개변수를 받습니다. 요컨대, 우리가 가지고 있는 impl의 타입은 다음과 같습니다.
V_O = ConvertTo<i32> for Object
그리고 함수 프로토타입이 기대하는 impl의 타입은 다음과 같습니다.
V_S = ConvertTo<i32> for String
여느 인자와 마찬가지로, 주어진 값의 타입(V_O)이 기대되는 타입(V_S)의 서브타입이면 이는 허용됩니다. 그렇다면 V_O <: V_S입니까? 답은 여러 매개변수의 변성에 따라 달라집니다. 이 경우 Self 매개변수는 반공변이고 A는 공변이므로, 다음을 의미합니다.
V_O <: V_S iff
i32 <: i32
String <: Object
이 조건들이 충족되므로 문제가 없습니다.
변성과 연관 타입
연관 타입을 가진 트레이트는, 적어도 프로젝션 표현식은, 모든 입력에 대해 불변이어야 합니다. 이것이 왜 타당한지 알아보기 위해, 트레이트 참조에 대한 서브타이핑이 무엇을 의미하는지 생각해 봅시다:
<T as Trait> <: <U as Trait>
이는 T as Trait임을 알면 U as Trait임도 안다는 뜻입니다. 또한 이를 딕셔너리 전달 방식으로 생각하면, <U as Trait>에 대한 딕셔너리가 기대되는 곳에서 <T as Trait>에 대한 딕셔너리를 안전하게 사용할 수 있다는 뜻입니다.
문제는 <T as Trait>에서 타입을 프로젝션해 낼 수 있을 때, T==U가 아닌 한 <U as Trait>에서 프로젝션된 타입과의 관계를 전혀 알 수 없다는 점입니다(자세한 내용은 #21726을 참고하십시오). Trait를 불변으로 만들면 이것이 성립함이 보장됩니다.
또 다른 관련 이유는, 연관 타입을 가진 트레이트를 불변으로 만들지 않으면 프로젝션이 더 이상 단일 결과를 갖는 함수가 아니게 된다는 점입니다. 다음을 생각해 봅시다:
trait Identity { type Out; fn foo(&self); }
impl<T> Identity for T { type Out = T; ... }
이제 <&'static () as Identity>::Out이 있다면, 이는 임의의 'a에 대해 &'a ()로 유효하게 도출될 수 있습니다:
<&'a () as Identity> <: <&'static () as Identity>
if &'static () < : &'a () -- Identity is contravariant in Self
if 'static : 'a -- Subtyping rules for relations
반면 이 변경으로 <'static () as Identity>::Out은 항상 &'static ()가 됩니다(이후 별도로 'a ()로 업캐스트될 수는 있습니다). 이는 #21750을 해결하는 데 도움이 되었습니다.
일관성(Coherence)
참고: 이 문서는 @lcnr의 노트를 바탕으로 작성되었습니다.
일관성 검사는 트레이트 impl과 고유 impl이 다른 impl과 겹치는지를 모두 검출하는 과정입니다. (참고로, 고유 impl은 impl MyStruct {}처럼 구체 타입에 대한 impl입니다.)
겹치는 트레이트 impl은 항상 오류를 발생시키지만, 겹치는 고유 impl은 같은 이름의 메서드를 가지는 경우에만 오류가 됩니다.
겹침 검사는 두 부분으로 나뉩니다. 첫째는 겹침 검사로, 컴파일러가 현재 알고 있는 트레이트 구현과 고유 구현 사이의 겹침을 찾아냅니다.
그러나 일관성 검사는 현재 알려져 있지 않더라도 다른 impl이 존재할 수 있는 경우에도 오류를 발생시킵니다. 이는 업스트림 크레이트에 하위 호환되는 방식으로 추가될 수 있는 impl과 다운스트림 크레이트의 impl에 영향을 미칩니다. 이것을 고아 검사(Orphan check)라고 합니다.
겹침 검사
겹침 검사는 고유 impl과 트레이트 impl 모두에 대해 수행됩니다. 두 검사는 같은 겹침 검사 코드를 사용하지만, 실제로는 별개의 두 분석으로 수행됩니다. 겹침 검사는 항상 구현을 쌍으로 묶어 서로 비교합니다.
고유 impl 블록에 대한 겹침 검사는 fn check_item(coherence/inherent_impls_overlap.rs 내)을 통해 수행되며, 여기서 이 검사가 (적어도 작은 n에 대해서는) 실제로 impl 사이에 n^2번의 비교를 수행한다는 것을 매우 분명하게 확인할 수 있습니다.
트레이트의 경우, 부모와 겹치는 특수화 impl을 처리하기 위해 현재 이 검사는 특수화 그래프를 구축하는 과정의 일부로 수행되지만, 이는 향후 바뀔 수 있습니다.
두 경우 모두 모든 impl 쌍에 대해 겹침 여부를 검사합니다.
겹침은 때때로 부분적으로 허용됩니다.
- 마커 트레이트의 경우
- 특수화를 사용하는 경우
일반적으로는 허용되지 않습니다.
겹침 검사에는 여러 모드가 있습니다(OverlapMode 참조). 중요한 것은 명시적 부정 impl 검사와 암시적 부정 impl 검사가 있다는 점입니다. 두 검사 모두 겹침이 절대로 불가능하다는 것을 증명하려고 시도합니다.
명시적 부정 impl 검사
이 검사는 impl_intersection_has_negative_obligation에서 수행됩니다.
이 검사는 부정 트레이트 구현을 찾으려고 시도합니다. 예를 들면 다음과 같습니다.
#![allow(unused)]
fn main() {
struct MyCustomErrorType;
// both in your own crate
impl From<&str> for MyCustomErrorType {}
impl<E> From<E> for MyCustomErrorType where E: Error {}
}
이 예제에서는 MyCustomErrorType: From<&str>와 MyCustomErrorType: From<?E>를 얻게 되며, 이로부터 ?E = &str이 도출됩니다.
따라서 이 두 구현은 겹치게 됩니다. 그러나 libstd는 &str: !Error를 제공하므로 &str: Error의 긍정 구현이 절대 존재하지 않을 것임을 보장하며, 따라서 겹침이 없습니다.
이러한 종류의 부정 impl 검사를 위해서는 명시적인 부정 구현이 제공되어야 한다는 점에 유의하십시오. 이 기능은 현재 안정화되지 않았습니다.
암묵적 부정 impl 검사
이 검사는 impl_intersection_has_impossible_obligation에서 수행되며, 부정 트레이트 구현에 의존하지 않고 안정화되어 있습니다.
다음과 같은 코드가 있다고 가정해 봅시다.
#![allow(unused)]
fn main() {
impl From<MyLocalType> for Box<dyn Error> {} // in your own crate
impl<E> From<E> for Box<dyn Error> where E: Error {} // in std
}
이 경우 Box<dyn Error>: From<MyLocalType>와 Box<dyn Error>: From<?E>가 나오고, 그 결과 ?E = MyLocalType이 됩니다.
여러분의 크레이트에는 MyLocalType: Error가 없으며, 하위 크레이트는 MyLocalType(원격 타입)에 대해 Error(원격 트레이트)를 구현할 수 없습니다. 따라서 이 두 impl은 겹치지 않습니다. 중요한 점은 impl !Error for MyLocalType가 없더라도 이 방식이 동작한다는 것입니다.
HIR 타입 검사
hir_analysis 크레이트에는 “타입 수집“을 위한 소스와 여러 관련 기능이 들어 있습니다. 함수 본문 검사는 hir_typeck 크레이트에 구현되어 있습니다. 이 크레이트들은 타입 추론과 트레이트 해결에 크게 의존합니다.
타입 수집
타입 “수집“은 사용자가 작성한 구문적 요소를 나타내는 HIR의 타입(hir::Ty)을 컴파일러가 사용하는 내부 표현(Ty<'tcx>)으로 변환하는 과정입니다. where 절과 함수 시그니처의 다른 부분에 대해서도 비슷한 변환을 수행한다는 점에 유의하십시오.
그 차이를 이해하기 위해 다음 함수를 살펴봅시다.
struct Foo { }
fn foo(x: Foo, y: self::Foo) { ... }
// ^^^ ^^^^^^^^^
두 매개변수 x와 y는 각각 같은 타입이지만, 서로 다른 hir::Ty 노드를 갖게 됩니다. 이 노드들은 서로 다른 스팬을 가지며, 당연히 경로도 약간 다르게 인코딩합니다. 그러나 일단 Ty<'tcx> 노드로 “수집“되면, 두 노드는 완전히 같은 내부 타입으로 표현됩니다.
수집은 컴파일 중인 크레이트에 있는 여러 함수, 트레이트, 기타 항목에 대한 정보를 계산하는 쿼리의 묶음으로 정의됩니다. 이 쿼리들은 각각 프로시저 간 사항을 다룬다는 점에 유의하십시오. 예를 들어 함수 정의의 경우, 수집은 함수의 타입과 시그니처를 알아내지만 함수의 본문은 전혀 방문하지 않으며 지역 변수의 타입 표기도 검사하지 않습니다(그것은 타입 검사가 할 일입니다).
자세한 내용은 collect 모듈을 참조하십시오.
TODO: 타입 검사에 대해 실제로 설명해야 합니다… #1161
강제 변환
강제 변환은 값을 다른 타입으로 바꾸는 암시적 연산입니다. 강제 변환 지점은 강제 변환이 암묵적으로 수행될 수 있는 위치입니다. 강제 변환 지점에는 두 가지 종류가 있습니다:
- 일대일
- LUB(최소 상계, Least-Upper-Bound)
#![allow(unused)]
fn main() {
let one_to_one_coercion: &u32 = &mut 8;
let lub_coercion = match my_bool {
true => &mut 10,
false => &12,
};
}
어떤 강제 변환이 있으며 어떤 표현식이 강제 변환 지점인지에 대한 설명은 강제 변환에 관한 레퍼런스 페이지를 참조하십시오: https://doc.rust-lang.org/reference/type-coercions.html
일대일 강제 변환
일대일 강제 변환에서는 하나의 단일 타입을 알려진 대상 타입으로 강제 변환합니다. 위 예제에서는 &mut u32에서 &u32로의 강제 변환이 이에 해당합니다.
일대일 강제 변환은 FnCtxt::coerce를 호출하여 수행할 수 있습니다.
LUB 강제 변환
LUB 강제 변환에서는 소스 타입들의 집합을 알려지지 않은 어떤 대상 타입으로 강제 변환합니다. 일대일 강제 변환과 달리, LUB 강제 변환은 모든 소스 타입이 강제 변환되는 대상 타입을 만들어 냅니다.
위 예제에서는 &mut i32와 &i32 둘 모두에 대한 LUB 강제 변환이 이에 해당하며, 여기서 대상 타입 &i32가 만들어집니다.
“LUB 강제 변환”(최소 상계 강제 변환)이라는 이름은 이 강제 변환이 타입들의 집합을 받아, 두 소스 타입이 모두 강제 변환/서브타입화될 수 있는 가장 작은 강제 변환/서브타입 타입을 계산하는 방식에서 유래했습니다.
LUB 강제 변환을 수행하는 일반적인 과정은 다음과 같습니다:
// * 1
let mut coerce = CoerceMany::new(initial_lub_ty);
for expr in exprs {
// * 2
let expr_ty = fcx.check_expr_with_expectation(expr, expectation);
coerce.coerce(fcx, &cause, expr, expr_ty);
}
// * 3
let final_ty = coerce.complete(fcx);
여기에는 몇 가지 핵심 단계가 있습니다:
CoerceMany값을 생성하고 초기 lub를 선택하기- 각 표현식을 타입 검사하고 그 타입을 LUB 강제 변환의 일부로 등록하기
- LUB 강제 변환을 완료하여 결과로 나오는 lub 타입을 얻기
1단계
먼저 CoerceMany 값을 생성합니다. 이 값은 LUB 강제 변환에 필요한 모든 상태를 저장합니다. 일대일 강제 변환과 달리, LUB 강제 변환은 단일 함수 호출이 아닙니다. 타입 검사와 LUB 강제 변환의 진행을 번갈아 수행하고자 하기 때문입니다.
CoerceMany를 생성하려면 어떤 initial_lub 타입이 필요합니다. 이는 강제 변환의 대상과는 다릅니다. 대상은 (일대일 강제 변환과 달리) LUB 강제 변환의 입력이 아니라 출력입니다.
초기 lub 타입은 이 LUB 강제 변환이 적용되는 표현식의 Expectation으로부터 도출되어야 합니다. 이를 통해 LUB 강제 변환을 계산하면서 생긴 추론 제약이, 해당 LUB 강제 변환에 참여하는 이후 표현식들을 타입 검사하는 데 쓰이는 Expectation들로 전파될 수 있습니다.
이것이 미치는 영향에 대한 자세한 내용은 “불필요한 추론 제약” 절을 참조하십시오.
사용할 Expectation이 없다면 초기 lub 타입으로 쓸 새로운 추론 변수를 만들어야 합니다.
2단계
다음으로, LUB 강제 변환에 참여하는 각 표현식의 타입을 검사한 뒤 그 타입으로 CoerceMany::coerce를 호출합니다.
LUB 강제 변환에 참여하는 표현식이 실제로는 HIR에 존재하지 않는 경우도 있습니다. 예를 들어 피연산자가 없는 break 또는 return 표현식을 처리할 때는 ()가 LUB 강제 변환에 참여해야 합니다.
이러한 경우에는 CoerceMany::coerce_forced_unit 메서드를 사용할 수 있습니다.
CoerceMany::coerce 메서드와 coerce_forced_unit 메서드는 모두 새 타입 때문에 LUB 강제 변환을 만족할 수 없게 되면 오류를 발생시킵니다. 이 경우 LUB 강제 변환의 최종 타입은 오류 타입이 됩니다.
3단계
마지막으로 모든 표현식의 강제 변환이 끝나면 CoerceMany::complete를 호출하여 LUB 강제 변환의 최종 타입을 얻을 수 있습니다.
LUB 강제 변환의 결과 타입은 CoerceMany를 생성할 때 전달한 초기 lub 타입과 의미 있게 다를 수 있습니다. 항상 LUB 강제 변환의 결과 타입을 가져와 필요한 검사를 수행해야 합니다.
구현상의 세부 사항
조정
강제 변환 연산이 성공하면 그것이 어떤 종류의 강제 변환이었는지(예: unsize 강제 변환, 자동 역참조 등)를 기록합니다. 이는 강제 변환 연산의 일부로 처리되며, 진행 중인 TypeckResults에 조정(adjustment) 목록을 기록하는 방식으로 이루어집니다.
THIR을 구축할 때는 TypeckResults에 저장된 조정 정보를 가져와 모든 강제 변환 단계를 명시적으로 만듭니다. 컴파일러에서 이 시점 이후로는 강제 변환이라는 개념이 사실상 존재하지 않으며, MIR에는 명시적 캐스트와 서브타이핑만 남습니다.
TODO: 여기에 조정 장을 작성하고 링크하십시오
CoerceMany의 동작 방식
CoerceMany는 현재 lub 타입과 새로운 원본 타입을 반복해서 받아, 두 타입이 모두 강제 변환될 수 있는 새로운 lub 타입을 계산하는 방식으로 동작합니다. 두 타입을 받아 새로운 세 번째 타입을 계산하는 핵심 로직은 try_find_coercion_lub에서 찾을 수 있습니다.
#![allow(unused)]
fn main() {
fn foo() {}
fn bar() {}
let a = match my_bool {
true => foo,
true if other_bool => foo,
false => bar,
}
}
이 예제에서는 match 표현식을 타입 검사할 때 LUB 강제 변환이 수행됩니다. let 문에 알려진 타입이 없으므로, 이 LUB 강제 변환은 어떤 추론 변수 ?x를 초기 lub 타입으로 삼아 시작합니다.
이 LUB 강제 변환에 참여하는 표현식은 세 개입니다. LUB 강제 변환의 첫 번째 표현식은 특별합니다. 기존 초기 lub 타입과 함께 새 타입을 계산하는 대신, 첫 번째 표현식에서 초기 lub 타입으로 직접 강제 변환합니다.
true => foo,를 타입 검사하고 나면FnDef(Foo)타입을 얻습니다. 그런 다음CoerceMany::coerce를 호출하며, 이 메서드는FnDef(Foo)에서?x로의 일대일 강제 변환을 수행합니다. 이를 통해?x=FnDef(Foo)가 추론되고, LUB 강제 변환의 새로운 lub 타입을 얻게 됩니다.true if other_bool => foo,를 타입 검사하고 나면 다시 한번FnDef(Foo)타입을 얻습니다. 그런 다음CoerceMany::coerce를 호출하며, 이 메서드는 이전 lub 타입(FnDef(Foo))과 이 표현식의 타입(FnDef(Foo))으로부터 새로운 lub 타입을 계산하려고 시도합니다. 그 결과 lub 타입은FnDef(Foo)가 됩니다.false => bar,를 타입 검사하고 나면FnDef(Bar)타입을 얻습니다. 그런 다음CoerceMany::coerce를 호출하며, 이 메서드는 이전 lub 타입(FnDef(Foo))과 이 표현식의 타입(FnDef(Bar))으로부터 새로운 lub 타입을 계산하려고 시도합니다. 이 경우에는 두 함수 항목 타입을 모두 함수 포인터로 강제 변환하기로 하므로fn() -> ()타입을 얻게 됩니다.
이렇게 하여 fn() -> ()의 LUB 강제 변환에 대한 최종 타입을 얻습니다.
전이적 강제 변환
현재 대상 타입을 새 타입으로 반복해서 강제 변환하려고 시도하는 CoerceMany의 알고리즘은 현재 “전이적 강제 변환(Transitive Coercions)“을 초래합니다. LUB 강제 변환의 한 단계에서 어떤 표현식을 강제 변환한 뒤, 이후 단계에서 그 표현식을 다시 강제 변환할 수 있습니다.
struct Foo;
use std::ops::Deref;
impl Deref for Foo {
type Target = [u8; 2];
fn deref(&self) -> &[u8; 2] {
&[1; _]
}
}
fn main() {
match () {
_ if true => &Foo,
_ if true => &[1_u8; 2],
_ => &[1_u8; 2] as &[u8],
};
}
여기서는 초기 lub 타입이 ?x인 LUB 강제 변환이 있습니다. 첫 번째 단계에서는 &Foo에서 ?x로의 일대일 강제 변환을 수행합니다(첫 번째 단계는 특별하다는 점을 상기하십시오).
두 번째 단계에서는 현재 lub 타입인 &Foo와 새 타입인 &[u8; 2]로부터 새로운 lub 타입을 계산합니다. 첫 번째 표현식에 &Foo에서 &[u8; 2]로의 역참조 강제 변환을 수행하면 이 새로운 lub 타입은 &[u8; 2]가 됩니다.
세 번째 단계에서는 현재 lub 타입인 &[u8; 2]와 새 타입인 &[u8]로부터 새로운 lub 타입을 계산합니다. 처음 두 표현식에 &[u8; 2]에서 &[u8]로의 크기 제거 강제 변환을 수행하면 이 새로운 lub 타입은 &[u8]가 됩니다.
첫 번째 표현식이 두 번 강제 변환된다는 점에 주목하십시오. 한 번은 &Foo에서 &[u8; 2]로의 역참조 강제 변환이고, 그다음은 &[u8; 2]에서 &[u8]로의 크기 제거 강제 변환입니다.
전이적 강제 변환의 현재 구현은 망가져 있으며, 앞의 예제는 실제로 stable에서 ICE를 일으킵니다. LUB 강제 변환을 수행하는 로직은 전이적 강제 변환을 문제없이 만들어 낼 수 있지만, 컴파일러의 나머지 부분은 이를 처리하도록 갖추어져 있지 않습니다.
또한 일대일 강제 변환은 LUB 강제 변환이 만들어 낼 수 있는 전이적 강제 변환 중 상당수를 만들어 낼 수 없습니다. 예를 들어, 앞의 예제를 일대일 강제 변환으로 바꾸면 다음과 같은 컴파일 오류가 발생합니다:
struct Foo;
use std::ops::Deref;
impl Deref for Foo {
type Target = [u8; 2];
fn deref(&self) -> &[u8; 2] {
&[1; _]
}
}
fn main() {
let a: &[u8] = &Foo;
}
여기서는 &Foo에서 &[u8]로의 일대일 강제 변환을 시도하지만, 역참조 강제 변환 또는 크기 제거 강제 변환 중 하나만 수행할 수 있고 둘을 조합할 수는 없으므로 실패합니다.
try_find_coercion_lub는 어떻게 동작합니까
LUB 강제 변환에서 새로운 lub 타입을 계산하는 방법은 세 가지입니다.
- 현재 lub 타입과 새 타입을 모두 함수 포인터로 강제 변환합니다
- 현재 lub 타입을 새 타입으로 강제 변환합니다(또는 그 반대)
- 현재 lub 타입과 새 타입의 공통 상위 타입을 계산합니다
안타깝게도 실제 구현에서는 이 구조가 상당히 불분명하게 드러납니다.
공통 상위 타입 계산은 일대일 강제 변환 로직을 재사용하기 때문에 암묵적으로 일어나는데, 이 로직은 강제 변환이 실패하면 이미 서브타이핑을 처리합니다.
또한 현재 lub 타입과 새 타입을 모두 함수 포인터로 강제 변환하려고 할 때, 불필요한 강제 변환을 피하기 위해 공통 상위 타입을 먼저 계산하려고 시도합니다.
이 함수의 구조를 개념적 모델에 더 가깝게 맞추도록 개선할 여지가 있을 것입니다.
일대일 강제 변환의 use_lub 필드
일대일 강제 변환의 구현은 LUB 강제 변환의 일부로 재사용됩니다.
LUB 강제 변환이 시그니처를 관계 지을 때 단방향 서브타이핑을 사용하거나, 가능한 강제 변환이 없을 때 서브타이핑으로 대체하는 것은 잘못된 일입니다. 대신 두 타입의 공통 상위 타입을 계산해야 합니다.
Coerce의 use_lub 필드는 일반적인 서브타이핑을 수행할지(일대일 강제 변환의 경우), 아니면 공통 상위 타입을 계산할지(LUB 강제 변환의 경우)를 전환하기 위해 존재합니다.
Lub 계산
이론적으로 공통 상위 타입을 계산하는 일은 새 추론 변수 ?mutual_sup를 만든 다음 lub_ty <: ?mutual_sup와 new_ty <: ?mutual_sup를 요구하는 것만큼 간단해야 합니다. 실제로는 LUB 강제 변환이 특별한 TypeRelation인 LatticeOp를 사용합니다.
이는 주로 고차 랭크 타입의 서브타이핑/일반화가 상당히 망가져 있는 문제를 우회하기 위한 것입니다. 일반적인 서브타이핑과 달리, lub 타입 관계는 고차 랭크 타입을 만나면 불변성으로 전환됩니다.
이렇게 하면 고차 랭크 타입의 바인더들이 동등하도록 강제되므로, “가장 일반적인” 바인더를 고를 필요가 없어집니다. 그런 바인더를 고르기는 상당히 어렵습니다.
또한 공통 상위 타입을 계산하는 과정이 순서에 의존하지 않도록 합니다. 타입 a와 b가 주어졌을 때, a와 b의 공통 상위 타입을 계산한 결과가 b와 a의 공통 상위 타입을 계산한 결과와 같다면 바람직할 것입니다.
공통 상위 타입을 계산하는 단순한 방법을 사용한다면, 고차 타입과 서브타이핑에 관한 현재의 문제들 때문에 이 성질이 성립하지 않게 됩니다.
MIR 빌드 중에 강제 변환이 명시적인 MIR 연산으로 바뀌므로, LUB 강제 변환의 최종 타입을 계산하는 과정은 HIR 타입 검사 중에만 일어납니다. 이는 또한 공통 상위 타입을 계산하는 동작이 타입 추론에만 영향을 미치며 건전성과는 관련이 없다는 것을 의미합니다.
주의 사항
프로브
일대일 강제 변환과 LUB 강제 변환 모두 프로브로 되돌릴 수 없는 부수 효과를 가지므로, 프로브 내부에서 강제 변환을 수행할 때는 주의해야 합니다.
LUB 강제 변환은 강제 변환 단계가 실패하면 오류를 발생시키며, 이 때문에 프로브 내부에서 사용하기에 전적으로 적합합니다.
일대일 강제 변환과 LUB 강제 변환은 모두 성공 시 강제 변환된 표현식에 조정을 적용합니다. 따라서 프로브 내부에서 강제 변환 시도가 성공했다면, 그 프로브는 아무것도 롤백해서는 안 됩니다.
따라서 FnCtxt::coerce 호출을 commit_if_ok 안에 감싸는 것은 올바르지만, coerce 호출 이후에 Err를 반환한다면 그렇게 하는 것은 잘못입니다. probe 내부에서 FnCtxt::coerce를 호출하는 것 또한 잘못입니다.
CoerceMany는 probe나 commit_if_ok 내부에서 절대 사용해서는 안 됩니다.
Never-to-Any 강제 변환
never 타입(!)에서 추론 변수로 강제 변환하면, 그 추론 변수를 대상 타입으로 하는 NeverToAny 강제 변환이 만들어집니다. 이는 추론 변수를 never 타입과 단일화하는 것과 미묘하게 다릅니다.
어떤 추론 변수 ?x를 !와 단일화하려면 ?x가 실제로 !와 같아야 합니다. 그러나 NeverToAny 강제 변환에서는 ?x가 가능한 어떤 타입으로든 추론될 수 있습니다.
이러한 차이 때문에, 강제 변환의 초기 lub 타입이 추론 변수인 경우(예: 초기 lub 타입으로 사용할 Expectation이 없는 경우)에도 서브타이핑 대신 강제 변환을 사용하는 것이 여전히 중요합니다.
강제 변환을 거치지 않고 never 타입으로 잘못 추론하던 버그를 수정한 PR #147834를 참고하십시오.
서브타이핑으로의 폴백
서브타이핑은 강제 변환이 아니지만, FnCtxt::coerce와 CoerceMany::coerce/coerce_forced_unit는 모두 서브타이핑 덕분에 성공할 수 있습니다.
일대일 강제 변환에서는 원본 타입이 대상 타입의 서브타입이 되도록 강제하려고 시도합니다. LUB 강제 변환의 경우, 기존의 모든 타입의 상위 타입이 되는 타입을 계산하려고 시도합니다.
예를 들어 ?x를 u32로 일대일 강제 변환하면 서브타이핑으로 폴백하여 ?x eq u32로 추론합니다. 따라서 강제 변환이 실패했다면 그 후에 서브타이핑을 시도할 필요가 없습니다.
불필요한 추론 제약
Expectation의 타입을 초기 lub 타입으로 사용하면, 추론 변수가 LUB 강제 변환에 참여하는 표현식들의 타입에 의해 제약될 수 있습니다. 이러한 추론 변수는 실제로는 LUB 강제 변환의 최종 타입에 의해서만 제약되면 되므로, 이는 항상 바람직하지는 않습니다.
#![allow(unused)]
fn main() {
fn foo<T>(_: T) {}
fn a() {}
fn b() {}
foo::<?x>(match my_bool {
true => a,
false => b,
})
}
여기에는 첫 번째 표현식의 타입이 FnDef(a)이고 두 번째 표현식의 타입이 FnDef(b)인 LUB 강제 변환이 있습니다. ?x를 LUB 강제 변환의 초기 lub 타입으로 사용하면 다음과 같이 동작하게 됩니다.
- 표현식 1:
?x=FnDef(a)로 추론합니다. - 표현식 2:
FnDef(a), FnDef(b)사이의 강제 변환 lub를 찾으며, 그 결과는fn() -> ()입니다. - LUB 강제 변환의 최종 타입은
fn() -> ()입니다.?x eq fn() -> ()로 동일시하는데,?x는 이미FnDef(a)로 추론되었으므로 이는 사실상 성립하지 않는FnDef(a) eq fn() -> ()를 동일시하는 것입니다.
이러한 바람직하지 않은 추론 제약 중 일부(전부는 아닙니다)를 피하기 위해, LUB 강제 변환에 대한 Expectation이 추론 변수라면 이를 초기 lub 타입으로 사용하지 않습니다. 대신 새로운 추론 변수를 생성합니다. 예를 들어 위 코드 조각에서는 ?x를 사용하는 대신 초기 lub 타입으로 새로운 추론 변수 ?y를 만들게 됩니다.
- 표현식 1:
?y=FnDef(a)로 추론합니다. - 표현식 2:
FnDef(a), FnDef(b)사이의 강제 변환 lub를 찾으며, 그 결과는fn() -> ()입니다. - LUB 강제 변환의 최종 타입은
fn() -> ()이며,?x=fn() -> ()로 추론합니다.
새로운 추론 변수를 생성하지 않아 바람직하지 않은 추론 제약이 발생했던 사례는 #140283을 참고하십시오.
이 방법으로 모든 경우의 불필요한 제약을 피할 수 있는 것은 아니며, Expectation이 추론 변수인 가장 흔한 경우만 피할 수 있습니다. 이론적으로는 모든 경우에 이러한 제약을 피하는 것이 바람직하겠지만, 그렇게 하려면 상당히 복잡한 작업이 필요합니다.
메서드 조회
메서드 조회는 self 타입, 자동 역참조(autoderef), 트레이트 조회 등 여러 요인이 상호 작용하기 때문에 상당히 복잡할 수 있습니다. 이 파일은 그 과정의 개요를 제공합니다. 더 자세한 설명은 당연히 코드 자체에 있습니다.
메서드 조회를 이해하는 한 가지 방법은 receiver.method(...) 형태의 표현식을 더 명시적인 완전 한정 구문(이전에는 UFCS라고 불렸습니다)으로 변환한다고 생각하는 것입니다:
- 트레이트 호출의 경우
Trait::method(ADJ(receiver), ...) - 고유 메서드 호출의 경우
ReceiverType::method(ADJ(receiver), ...)
여기서 ADJ는 일종의 조정으로, 일반적으로 일련의 자동 역참조 뒤에 경우에 따라 자동 참조(autoref)가 이어지는 형태입니다(예: &**receiver). 그러나 때로는 그 과정에서 다른 조정과 강제 변환도 수행하며, 특히 크기 제거(unsizing)가 그러합니다(예: [T; n]에서 [T]로 변환).
메서드 조회는 두 가지 주요 단계로 나뉩니다:
- 탐색(
probe.rs). 프로브 단계는 어떤 메서드를 호출할지, 그리고 수신자를 어떻게 조정할지 결정하는 단계입니다. - 확정(
confirm.rs). 확정 단계는 이 선택을 “적용“하여 부가 테이블(side-table)을 갱신하고, 타입 변수를 단일화하며, 그 밖의 부수 효과가 있는 작업을 수행합니다.
이렇게 나누는 이유 중 하나는 캐싱에 더 적합하게 만들기 위해서입니다. 프로브 단계는 “pick”(probe::Pick)을 생성하며, 이는 여러 메서드 호출 지점에 걸쳐 캐시할 수 있도록 설계되었습니다. 따라서 여기에는 추론 변수나 기타 정보가 포함되지 않습니다.
프로브 단계
스텝
프로브 단계가 가장 먼저 하는 일은 일련의 스텝을 만드는 것입니다. 이는 리시버 타입을 더 이상 역참조할 수 없을 때까지 점진적으로 역참조하고, 선택적으로 “크기 제거(unsize)” 스텝을 적용하는 방식으로 이루어집니다. 따라서 리시버의 타입이 Rc<Box<[T; 3]>>라면 다음과 같은 결과가 나올 수 있습니다:
Rc<Box<[T; 3]>>Box<[T; 3]>[T; 3][T]
후보 수집
그런 다음 이 스텝들을 따라 탐색하여 후보 목록을 만듭니다. Candidate는 호출되는 메서드일 가능성이 있는 메서드 항목입니다. 각 후보에 대해 명시적 self를 고려한 “변환된 self 타입“을 도출합니다.
후보는 고유(inherent) 후보와 확장(extension) 후보의 두 종류로 분류됩니다.
고유 후보(inherent candidates)는 리시버 타입 자체에서 도출되는 후보입니다. 따라서 어떤 명목 타입 Foo(예: 구조체)의 리시버가 있다면, impl Foo와 같은 impl 안에 정의된 모든 메서드는 고유 메서드입니다. 고유 메서드를 사용하는 데에는 아무것도 임포트할 필요가 없습니다. 고유 메서드는 타입 자체에 연관되어 있기 때문입니다. 고유 impl은 해당 타입과 같은 크레이트에서만 정의할 수 있다는 점에 유의하십시오.
확장 후보(extension candidates)는 임포트된 트레이트에서 도출됩니다. 트레이트 ToString을 임포트한 상태에서 to_string()을 메서드로 호출하면, ToString의 각 impl에 있는 to_string() 정의를 후보로 나열합니다. 이러한 종류의 메서드 호출을 “확장 메서드(extension methods)“라고 합니다.
그러면 예제를 계속 살펴봅시다. 리시버 Rc<Box<[T; 3]>>로 메서드 foo를 호출하고 있고, 타입 Rc<U>에 대해 &self로 이 메서드를 정의하는 트레이트 Foo가 있으며, 타입 Box에도 foo를 정의하되 &mut self로 정의하는 메서드가 있다고 가정해 보십시오. 그러면 다음과 같은 두 후보가 생길 수 있습니다.
- 확장 후보로서의
&Rc<U> - 고유 후보로서의
&mut Box<U>
후보 탐색
마지막으로, 실제로 메서드를 선택하기 위해 단계들을 차례로 따라 내려가며 리시버 타입을 후보 타입들과 매칭해 봅니다. 각 단계에서는 자동 참조(auto-ref)와 자동 가변 참조(auto-mut-ref)도 고려하여, 그렇게 했을 때 후보 중 하나라도 매칭되는지 확인합니다. 이렇게 얻은 각 리시버 타입에 대해 확장 후보보다 고유 후보를 먼저 고려합니다. 한 그룹 안에 매칭되는 후보가 여러 개 있으면 오류를 보고합니다. 단, 같은 트레이트의 여러 impl은 하나의 매칭으로 취급합니다. 그렇지 않으면 처음 찾은 매칭을 선택합니다.
예제의 경우 첫 번째 단계는 Rc<Box<[T; 3]>>이며, 이 타입 자체는 어떤 후보와도 매칭되지 않습니다. 하지만 이를 자동 참조하면 &Rc<U>와 매칭되는 타입 &Rc<Box<[T; 3]>>를 얻습니다. 그런 다음 해당 impl에 나타나는 모든 where 절을 재귀적으로 고려합니다. 그 절들이 매칭되면(또는 매칭되지 않는다고 배제할 수 없으면) 이 메서드를 선택하게 됩니다. 그렇지 않으면 일련의 단계를 계속 따라 내려갑니다.
상수 제네릭
상수 인자의 종류
존재하는 ty::Const 종류 대부분은 기존 타입 종류와 직접 대응합니다. 예를 들어 ConstKind::Param은 TyKind::Param에 해당합니다.
여기서 주목할 만한 주요 사항은 다음과 같습니다.
ConstKind::Unevaluated는TyKind::Alias에 해당하며, 장기적으로는 이름을 바꾸어야 합니다(ty::AliasKind에 대응하는AliasConstKind의 도입도 함께 필요합니다).ConstKind::Value는 단형화 이후ty::Const의 최종 값입니다. 이는TyKind::Str나TyKind::ADT처럼 완전히 구체적인 것들과 다소 비슷합니다.
상수 인자의 모든 종류와 이들이 타입 시스템에서 실제로 어떻게 표현되는지에 대한 전체 목록은 ConstKind 타입을 참고하십시오.
추론 변수는 별로 특별할 것이 없으며, 거의 모든 곳에서 타입 추론 변수와 동일하게 취급됩니다. 상수 매개변수 역시 마찬가지로 특별할 것이 없으며, 거의 모든 곳에서 타입 매개변수의 사용과 동일합니다. 그러나 파싱, 이름 해석, AST 로워링 과정에서 이들을 처리하는 방식에는 몇 가지 흥미롭고 미묘한 점이 있습니다: ambig-unambig-ty-and-consts.
익명 상수
익명 상수(anonymous 상수 항목의 줄임말)는 상수 제네릭에서 임의의 표현식을 나타내는 방식으로, 예를 들어 1 + 1, foo() 또는 그냥 0인 배열 길이가 이에 해당합니다. 이는 상수 제네릭에만 있는 것으로, 타입에는 실제로 이에 대응하는 것이 없습니다.
디슈가링
#![allow(unused)]
fn main() {
struct Foo<const N: usize>;
type Alias = [u8; 1 + 1];
}
이 예제에는 익명 상수로 표현되는 1 + 1이라는 상수 인자(배열 길이)가 있습니다. 디슈가링하면 대략 다음과 같습니다.
#![allow(unused)]
fn main() {
struct Foo<const N: usize>;
const ANON: usize = 1 + 1;
type Alias = [u8; ANON];
}
여기서 [u8; ANON]의 배열 길이는 그 자체로 ANON의 사용을 담은 익명 상수가 아니라, ANON 상수 항목을 일종의 “직접” 사용하는 것(ConstKind::Unevaluated)입니다.
익명 상수는 자신이 속한 항목의 제네릭 매개변수를 전혀 상속받지 않습니다.
#![allow(unused)]
fn main() {
struct Foo<const N: usize>;
type Alias<T: Sized> = [T; 1 + 1];
// Desugars To;
struct Foo<const N: usize>;
const ANON: usize = 1 + 1;
type Alias<T: Sized> = [T; ANON];
}
Alias에는 타입 매개변수 T와 where 절 T: Sized가 모두 있지만, ANON 상수에는 제네릭 매개변수도 where 절도 없다는 점에 주목하십시오. 이 디슈가링은 익명 상수가 제네릭 매개변수를 사용할 수 없도록 강제하는 방법의 일부입니다.
익명 상수가 실제 상수 항목으로 디슈가링된다고 생각하면 이해에 도움이 되지만, 컴파일러가 실제로 이런 방식으로 구현되어 있지는 않습니다.
AST 로워링 시점에는 아직 익명 상수의 타입을 알지 못하므로, 타입을 명시적으로 작성한 실제 HIR 항목으로 디슈가링할 수 없습니다. 이를 해결하기 위해 DefKind::AnonConst와 hir::Node::AnonConst가 있으며, 이들은 실제로는 디슈가링할 수 없는 이러한 익명 상수 항목을 나타내는 데 사용됩니다.
이러한 익명 상수의 타입은 type_of 쿼리로 얻을 수 있습니다. 그러나 type_of 쿼리에는 실제로 타입을 계산하는 로직이 들어 있지 않습니다(사실 호출하면 그냥 ICE가 발생합니다). 대신 HIR Ty 로워링이 로워링되는 모든 익명 상수에 대해 type_of 쿼리의 값을 주입하는 역할을 맡습니다. HIR Ty 로워링은 익명 상수가 인자로 전달되는 상수 매개변수의 타입을 보고 익명 상수의 타입을 결정할 수 있습니다.
TODO: 쿼리 주입에 관한 장을 작성하고 여기에 링크하십시오.
어떤 의미에서 앞의 예제들을 디슈가링한 결과는 다음과 같습니다.
#![allow(unused)]
fn main() {
struct Foo<const N: usize>;
type Alias = [u8; 1 + 1];
// sort-of desugars to pseudo-rust:
struct Foo<const N: usize>;
const ANON = 1 + 1;
type Alias = [u8; ANON];
}
Alias의 배열 타입에 대해 HIR ty 로워링을 수행할 때 배열 길이도 함께 로워링하며, type_of(ANON) -> usize를 주입합니다. 결과적으로 ANON 상수 항목의 타입은 HIR을 구성할 때가 아니라 컴파일러의 이후 단계 중 어딘가에서 설정됩니다.
이러한 디슈가링이 모두 끝나면, 타입 시스템에서의 최종 표현(즉 ty::Const로서의 표현)은 AnonConst의 DefId를 가진 ConstKind::Unevaluated입니다. 이는 익명 상수를 거치지 않고 실제 상수 항목의 사용을 표현한다면(예: min_generic_const_args가 활성화된 경우) 그것을 표현하는 방식과 동일합니다.
이를 통해 const “별칭“의 표현을 TyKind::Alias의 표현과 동일하게 만들 수 있습니다. 제대로 된 HIR 본문이 있으면 많은 코드를 재사용할 수도 있습니다. 예를 들어 HIR 타입 검사와 MIR로의 모든 로워링 단계를 재사용할 수 있으며, MIR에서는 다시 const 평가를 재사용할 수 있습니다.
제네릭 매개변수 부재 강제하기
익명 상수가 제네릭 매개변수를 사용할 수 없도록 강제하는 방법은 세 가지입니다:
- 이름 해석은 익명 상수 내부에서는 경로를 제네릭 매개변수로 해석하지 않습니다
- HIR Ty 로워링은 익명 상수 내부에서 제네릭 매개변수를 참조하는 타입에 대한
Self타입 별칭을 만나면 오류를 냅니다 - 익명 상수는 부모 정의로부터 where 절이나 제네릭을 상속하지 않습니다(즉
generics_of에는 익명 상수의 부모가 포함되지 않습니다)
#![allow(unused)]
fn main() {
// *1* Errors in name resolution
type Alias<const N: usize> = [u8; N + 1];
//~^ ERROR: generic parameters may not be used in const operations
// *2* Errors in HIR Ty lowering:
struct Foo<T>(T);
impl<T> Foo<T> {
fn assoc() -> [u8; { let a: Self; 0 }] {}
//~^ ERROR: generic `Self` types are currently not permitted in anonymous constants
}
// *3* Errors due to lack of where clauses on the desugared anon const
trait Trait<T> {
const ASSOC: usize;
}
fn foo<T>() -> [u8; <()>::ASSOC]
//~^ ERROR: no associated item named `ASSOC` found for unit type `()`
where
(): Trait<T> {}
}
두 번째 사항은 특히 미묘합니다. HIR Ty 로워링을 잘못 구현하여 익명 상수가 제네릭 매개변수를 사용할 수 없다는 점을 제대로 강제하지 못하기가 매우 쉽기 때문입니다. 기존 검사는 지나치게 보수적이면서도, 일부 제네릭 매개변수가 익명 상수의 본문에 들어가는 것을 의도치 않게 허용합니다 #144547.
익명 상수에서 제네릭 매개변수를 잘못 허용하면 때때로 ICE가 발생할 수 있지만, 잘못된 형식의 프로그램을 받아들이는 결과로 이어질 수도 있습니다.
세 번째 사항 역시 다소 미묘합니다. 부모 항목의 where 절을 하나도 상속하지 않으므로, 제네릭 매개변수를 언급하는 스코프 내 where 절에 근거하여 트레이트 해결이 추론 변수를 제네릭 매개변수로 추론하는 상황이 생길 수 없습니다. 예를 들어, <() as Trait<?x>>::ASSOC 표현식과 스코프 내의 (): Trait<T> where 절로부터 ?x=T를 추론하는 경우입니다.
또한 이렇게 하면, 익명 상수에서 제네릭 매개변수를 실제로 의도치 않게 허용하게 되더라도 컴파일러가 ICE를 일으키거나 적어도 부수적으로 어떤 종류의 오류를 낼 가능성이 훨씬 높아집니다. 익명 상수의 환경에는 제네릭 매개변수를 제대로 처리하는 데 필요한 정보가 전혀 없기 때문입니다.
배열 반복 표현식
위의 모든 내용에 대한 유일한 예외는 배열 표현식의 반복 횟수입니다. 하위 호환성을 위한 임시방편으로, 반복 횟수 상수 인자에서는 제네릭 매개변수를 사용할 수 있도록 허용합니다.
#![allow(unused)]
fn main() {
fn foo<T: Sized>() {
let a = [1_u8; size_of::<T>()];
}
}
그러나 익명 상수 상수 인자에서 제네릭 매개변수를 허용할 때 생기는 문제 대부분을 피하기 위해, 해당 상수가 단형화 이전(예: 타입 검사 중)에 평가되어야 한다고 요구합니다. 어떤 의미에서는 제네릭 매개변수가 의미상 사용되지 않는 경우에만 여기서 허용하는 것입니다.
앞의 예시에서 익명 상수는 어떤 타입 매개변수 T에 대해서도 평가할 수 있습니다. 크기가 정해진 타입에 대한 원시 포인터는 항상 같은 크기(예: 64비트 플랫폼에서는 8)를 가지기 때문입니다.
구문상으로는 제네릭 매개변수를 포함하지만 실제로는 평가가 성공하는 데 그것들에 의존하지 않은 익명 상수를 평가했음을 감지하면, const_evaluatable_unchecked FCW를 발생시킵니다. 이는 상수 인자에서 제네릭 매개변수를 사용하는 더 많은 방식, 예를 들어 min_generic_const_args나 (현재는 폐기된) generic_const_exprs가 안정화되면 하드 오류가 될 예정입니다.
이 FCW의 구현은 여기에서 찾을 수 있습니다: const_eval_resolve_for_typeck
generic_const_parameter_types와의 비호환성
const N: [u8; M]이나 const N: Foo<T>와 같은 상수 매개변수를 지원하는 것은 현재의 익명 상수 구조와 잘 맞지 않습니다. 여기에는 두 가지 이유가 있습니다:
-
익명 상수는 제네릭 매개변수를 사용할 수 없으므로, 그 타입 또한 제네릭 매개변수를 참조할 수 없습니다. 이는 타입이 여전히 제네릭 매개변수를 참조하는 상수 매개변수의 인자로 익명 상수를 사용하는 것이 근본적으로 불가능하다는 뜻입니다.
#![allow(unused)] #![feature(adt_const_params, generic_const_parameter_types)] fn main() { fn foo<const N: usize, const M: [u8; N]>() {} fn bar<const N: usize>() { // There is no way to specify the const argument to `M` foo::<N, { [1_u8; N] }>(); } } -
현재는 HIR ty 로워링 중에 익명 상수를 로워링할 때 그 타입을 알고 있어야 합니다. 제네릭 상수 매개변수 타입이 있으면, 현재 알려진 타입에 추론 변수가 포함되어 있을 수 있습니다(즉, 아직 완전히 알려지지 않았을 수 있습니다).
#![allow(unused)] #![feature(adt_const_params, generic_const_parameter_types)] fn main() { fn foo<const N: usize, const M: [u8; N]>() {} fn bar() { // The const argument to `N` must be explicitly specified // even though it is able to be inferred foo::<_, { [1_u8; 3] }>(); } }
generic_const_parameter_types가 나머지 상수 제네릭과 잘 맞물려 동작하게 만드는 올바른 방법이 무엇인지는 현재 명확하지 않습니다.
generic_const_exprs를 사용하면 제네릭 매개변수를 참조하는 타입을 가진 익명 상수를 허용할 수 있었겠지만, 그 설계는 결국 실현할 수 없는 것으로 드러났습니다.
min_generic_const_args는 일부 표현식(예: 배열 생성)을 익명 상수 없이 표현할 수 있게 하여 이러한 문제를 피할 수 있게 해 줄 것입니다. 다만 이것으로 충분한지는 아직 결정되지 않았습니다.
상수 인자의 타입 검사
상수 인자가 적격이려면, 그것이 인자로 전달되는 상수 매개변수와 같은 타입을 가져야 합니다. 예를 들어, 배열의 길이 매개변수는 usize 타입을 가지므로, 배열 길이에 대한 bool 타입의 상수 인자는 적격이 아닙니다.
#![allow(unused)]
fn main() {
type Alias<const B: bool> = [u8; B];
//~^ ERROR:
}
이를 검사하기 위해 ClauseKind::ConstArgHasType(ty::Const, Ty)가 있으며, 항목에 정의된 각 상수 매개변수마다 그에 상응하는 ConstArgHasType 절을 해당 항목의 where 절 목록으로 디슈가링합니다. 이렇게 하면 어떤 것의 모든 절을 증명하여 적격성을 검사할 때마다, 모든 상수 인자가 올바른 타입을 가지는지도 함께 검사하게 됩니다.
#![allow(unused)]
fn main() {
fn foo<const N: usize>() {}
// desugars to in pseudo-rust
fn foo<const N>()
where
// ConstArgHasType(N, usize)
N: usize, {}
}
ConstArgHasType 목표의 증명은 먼저 상수 인자의 타입을 계산한 다음, 이를 주어진 타입과 동일시하는 방식으로 구현됩니다. 상수 인자의 타입을 계산하는 방식의 대략적인 개요는 다음과 같습니다.
ConstKind::Param(N)은ParamEnv에서 조회하여ConstArgHasType(N, ty)절을 찾을 수 있습니다ConstKind::Value는 값의 타입을 자체적으로 저장하므로 간단히 접근할 수 있습니다ConstKind::Unevaluated는type_of쿼리를 호출하여 타입을 계산할 수 있습니다- 더 자세한 내용은
ConstArgHasType목표를 증명하는 구현을 참고하십시오
ConstArgHasType는 상수 인자가 올바른 타입을 가지는지 검사하는, 건전성에 핵심적인 바로 그 방식입니다. 그러나 일부 경우에는 상수 인자의 타입을 다른 방식으로 간접적으로 검사하기도 합니다.
#![allow(unused)]
fn main() {
type Alias = [u8; true];
// desugars to
const ANON: usize = true;
type Alias = [u8; ANON];
}
익명 상수의 타입을 상수 매개변수의 타입으로 공급함으로써, 해당 익명 상수가 관련된 ConstArgHasType 목표가 성공함을 보장합니다. 익명 상수의 타입이 상수 매개변수의 타입과 일치하지 않는 경우, 실제로는 익명 상수의 본문을 타입 검사할 때 타입 검사 오류가 발생합니다.
위 예제에 비추어 보면, 이는 ANON이 usize 타입을 가지므로 [u8; ANON]은 적격 타입이지만, usize 타입의 상수 항목에서 true를 반환할 수 없으므로 ANON의 본문은 적격하지 않아 타입 검사 오류가 발생하는 것에 해당합니다.
불투명 타입(타입 별칭 impl Trait)
불투명 타입은 특정 트레이트 집합만을 인터페이스로 노출하는 불투명 타입 별칭을 선언하기 위한 구문이며, 그 이면의 구체적인 타입은 불투명 타입이 사용되는 특정 위치들로부터 추론됩니다.
이는 타입 별칭 안에서 impl Trait를 사용하여 표현합니다. 예를 들면 다음과 같습니다.
type Foo = impl Bar;
이는 Foo라는 이름의 불투명 타입을 선언하며, 이 타입에 대해 알려진 정보는 Bar를 구현한다는 것뿐입니다. 따라서 Foo에는 Bar의 인터페이스는 무엇이든 사용할 수 있지만, 그 밖의 것은 사용할 수 없습니다(구체 타입이 다른 트레이트를 구현하는지 여부와 관계없이).
구체적인 배경 타입이 있어야 하므로, ( 2025년 5월 기준) “정의 사용 위치(defining use site)“에서 불투명 타입을 사용하여 그 타입을 표현할 수 있습니다.
struct Struct;
impl Bar for Struct { /* stuff */ }
#[define_opaque(Foo)]
fn foo() -> Foo {
Struct
}
다른 모든 “정의 사용 위치“도 정확히 같은 타입을 만들어야 합니다.
불투명 타입에 대한 타입 별칭을 정의하는 것은 불안정 기능이라는 점에 유의하십시오. 이를 사용하려면 nightly가 필요하며, 파일에는 #![feature(type_alias_impl_trait)] 애너테이션을, 불투명 타입을 구체 타입과 연결하는 메서드에는 #[define_opaque(Foo)] 애너테이션을 달아야 합니다. 전체 예제는 다음과 같습니다.
#![allow(unused)]
#![feature(type_alias_impl_trait)]
fn main() {
trait Bar { /* stuff */ }
type Foo = impl Bar;
struct Struct;
impl Bar for Struct { /* stuff */ }
#[define_opaque(Foo)]
fn foo() -> Foo {
Struct
}
}
정의 사용 위치
현재는 함수의 반환 값만이 불투명 타입의 정의 사용 위치가 될 수 있습니다(그리고 해당 함수의 반환 타입에 불투명 타입이 포함된 경우에만 가능합니다).
불투명 타입의 정의 사용은 불투명 타입 정의의 부모 안에 있는 어떤 코드든 될 수 있습니다. 여기에는 불투명 타입의 모든 형제와 그 형제들의 모든 자식이 포함됩니다.
“타입 시스템이 무엇을 하는지 이해하려다가 실수로 머릿속에서 무한 루프를 돌려 개발자에게 치명적인 뇌 손상을 입히지 않기“를 위한 이니셔티브는 불투명 타입의 자식이 정의 사용 위치가 되는 것을 허용하지 않기로 결정했습니다.
연관 불투명 타입
연관 불투명 타입은 같은 트레이트 impl에 있는 다른 모든 연관 항목이나 그 연관 항목들의 자식에 의해 정의될 수 있습니다. 예를 들면 다음과 같습니다.
trait Baz {
type Foo;
fn foo() -> Self::Foo;
}
struct Quux;
impl Baz for Quux {
type Foo = impl Bar;
fn foo() -> Self::Foo { ... }
}
이 경우에도 nightly와 (앞의 것과는 다른) #![feature(impl_trait_in_assoc_type)] 애너테이션을 사용해야 합니다. 불투명 타입이 (연관 타입 뒤에서) 함수 시그니처에 언급되므로 메서드에 더 이상 #[define_opaque(Foo)]가 필요하지 않다는 점에 유의하십시오. 전체 예제는 다음과 같습니다.
#![feature(impl_trait_in_assoc_type)]
trait Bar {}
struct Zap;
impl Bar for Zap {}
trait Baz {
type Foo;
fn foo() -> Self::Foo;
}
struct Quux;
impl Baz for Quux {
type Foo = impl Bar;
fn foo() -> Self::Foo { Zap }
}
불투명 타입(impl Trait)의 추론
이 페이지에서는 컴파일러가 불투명 타입의 숨겨진 타입을 추론하는 방법을 설명합니다. 이러한 종류의 타입 추론은 다른 종류의 타입 추론과 달리 여러 함수와 함수 본문에 걸쳐 동작할 수 있기 때문에 특히 복잡합니다.
실행 예제
동작 방식을 설명하기 위해 예제 하나를 살펴봅시다.
#![feature(type_alias_impl_trait)]
mod m {
pub type Seq<T> = impl IntoIterator<Item = T>;
#[define_opaque(Seq)]
pub fn produce_singleton<T>(t: T) -> Seq<T> {
vec![t]
}
#[define_opaque(Seq)]
pub fn produce_doubleton<T>(t: T, u: T) -> Seq<T> {
vec![t, u]
}
}
fn is_send<T: Send>(_: &T) {}
pub fn main() {
let elems = m::produce_singleton(22);
is_send(&elems);
for elem in elems {
println!("elem = {:?}", elem);
}
}
이 코드에서 불투명 타입은 Seq<T>입니다. 이 타입의 정의 스코프는 모듈 m입니다. 이 타입의 숨겨진 타입은 Vec<T>이며, 이는 m::produce_singleton과 m::produce_doubleton으로부터 추론됩니다.
main 함수에서는 불투명 타입이 정의 스코프 밖에 있습니다. main이 m::produce_singleton을 호출하면 불투명 타입 Seq<i32>에 대한 참조를 돌려받습니다. is_send 호출은 Seq<i32>: Send인지 검사합니다. Send는 impl 트레이트의 바운드 목록에 없지만, 자동 트레이트 누출(auto-trait leakage) 덕분에 이것이 성립함을 추론할 수 있습니다. for 루프의 디슈가링(desugaring)은 Seq<T>: IntoIterator를 요구하며, 이는 Seq<T>에 선언된 바운드로부터 증명할 수 있습니다.
main의 타입 검사
먼저 main을 타입 검사할 때 어떤 일이 일어나는지 살펴봅시다. 처음에 produce_singleton을 호출하며, 반환 타입은 불투명 타입 OpaqueTy입니다.
for 루프의 타입 검사
for 루프는 in elems 부분을 IntoIterator::into_iter(elems)로 디슈가링합니다. elems의 타입은 Seq<T>이므로, 타입 검사기는 Seq<T>: IntoIterator 의무(obligation)를 등록합니다. Seq<T>는 해당 트레이트에 대한 바운드를 가진 불투명 타입(impl IntoIterator<Item = T>)이므로 이 의무는 자명하게 충족됩니다. U: Foo where 바운드가 있으면 U가 Foo를 자명하게 충족하는 것과 마찬가지로, 불투명 타입의 바운드도 타입 검사기가 사용할 수 있으며 의무를 이행하는 데 쓰입니다.
for 루프에서 elem의 타입은 <Seq<T> as IntoIterator>::Item, 즉 T로 추론됩니다. 타입 검사기는 어느 시점에서도 숨겨진 타입에 관심을 두지 않습니다.
is_send 호출의 타입 검사
자동 트레이트 바운드를 증명하려 할 때는 먼저 위와 같은 과정을 반복하여 해당 자동 트레이트가 불투명 타입의 바운드 목록에 있는지 확인합니다. 이것이 실패하면 불투명 타입의 숨겨진 타입을 드러내는데, 이는 일반적으로 드러내는 것이 아니라 오직 이 특정 트레이트 바운드를 증명하기 위해서만 이루어집니다. 드러내기는 불투명 타입의 DefId에 대해 type_of 쿼리를 호출하여 수행합니다. 이 쿼리는 내부적으로 정의 함수(들)에 숨겨진 타입을 요청하여 그것을 반환합니다(자세한 내용은 type_of에 관한 절을 참고하십시오).
타입 검사 단계의 흐름도
flowchart TD
TypeChecking["type checking `main`"]
subgraph TypeOfSeq["type_of(Seq<T>) query"]
WalkModuleHir["Walk the HIR for the module `m`\nto find the hidden types from each\nfunction/const/static within"]
VisitProduceSingleton["visit `produce_singleton`"]
InterimType["`produce_singleton` hidden type is `Vec<T>`\nkeep searching"]
VisitProduceDoubleton["visit `produce_doubleton`"]
CompareType["`produce_doubleton` hidden type is also Vec<T>\nthis matches what we saw before ✅"]
Done["No more items to look at in scope\nReturn `Vec<T>`"]
end
BorrowCheckProduceSingleton["`borrow_check(produce_singleton)`"]
TypeCheckProduceSingleton["`type_check(produce_singleton)`"]
BorrowCheckProduceDoubleton["`borrow_check(produce_doubleton)`"]
TypeCheckProduceDoubleton["`type_check(produce_doubleton)`"]
Substitute["Substitute `T => u32`,\nyielding `Vec<i32>` as the hidden type"]
CheckSend["Check that `Vec<i32>: Send` ✅"]
TypeChecking -- trait code for auto traits --> TypeOfSeq
TypeOfSeq --> WalkModuleHir
WalkModuleHir --> VisitProduceSingleton
VisitProduceSingleton --> BorrowCheckProduceSingleton
BorrowCheckProduceSingleton --> TypeCheckProduceSingleton
TypeCheckProduceSingleton --> InterimType
InterimType --> VisitProduceDoubleton
VisitProduceDoubleton --> BorrowCheckProduceDoubleton
BorrowCheckProduceDoubleton --> TypeCheckProduceDoubleton
TypeCheckProduceDoubleton --> CompareType --> Done
Done --> Substitute --> CheckSend
type_of 쿼리 내부
type_of 쿼리를 불투명 타입 O에 적용하면 숨겨진 타입을 반환합니다. 그 숨겨진 타입은 O의 정의 스코프 내에 있는 각 제약 함수의 결과를 결합하여 계산됩니다.
flowchart TD
TypeOf["type_of query"]
TypeOf -- find_opaque_ty_constraints --> FindOpaqueTyConstraints
FindOpaqueTyConstraints --> Iterate
Iterate["Iterate over each item in defining scope"]
Iterate -- For each item --> TypeCheck
TypeCheck["Check typeck(I) to see if it constraints O"]
TypeCheck -- I does not\nconstrain O --> Iterate
TypeCheck -- I constrains O --> BorrowCheck
BorrowCheck["Invoke mir_borrowck(I) to get hidden type\nfor O computed by I"]
BorrowCheck --> PreviousType
PreviousType["Hidden type from I\nsame as any previous hidden type\nfound so far?"]
PreviousType -- Yes --> Complete
PreviousType -- No --> ReportError
ReportError["Report an error"]
ReportError --> Complete["Item I complete"]
Complete --> Iterate
FindOpaqueTyConstraints -- All constraints found --> Done
Done["Done"]
불투명 타입을 다른 타입과 관계 짓기
불투명 타입의 숨겨진 타입이 제약되는 중심 지점은 한 곳뿐이며, 바로 handle_opaque_type 함수입니다. 흥미롭게도 이 함수는 타입 두 개를 받으므로 아무 타입 두 개나 전달할 수 있지만, 그중 하나는 불투명 타입이어야 합니다. 순서는 진단에서만 중요합니다.
flowchart TD
subgraph typecheck["type check comparison routines"]
equate.rs
sub.rs
lub.rs
end
typecheck --> TwoSimul
subgraph handleopaquetype["infcx.handle_opaque_type"]
TwoSimul["Defining two opaque types simultaneously?"]
TwoSimul -- Yes --> ReportError["Report error"]
TwoSimul -- No --> MayDefine -- Yes --> RegisterOpaqueType --> AlreadyHasValue
MayDefine -- No --> ReportError
MayDefine["In defining scope OR in query?"]
AlreadyHasValue["Opaque type X already has\na registered value?"]
AlreadyHasValue -- No --> Obligations["Register opaque type bounds\nas obligations for hidden type"]
RegisterOpaqueType["Register opaque type with\nother type as value"]
AlreadyHasValue -- Yes --> EquateOpaqueTypes["Equate new hidden type\nwith old hidden type"]
end
쿼리와의 상호작용
쿼리가 불투명 타입을 처리할 때는 자신이 정의 스코프 안에 있는지 판단할 수 없으므로, 그냥 정의 스코프 안에 있다고 가정합니다.
등록된 숨겨진 타입은 QueryResponse 구조체의 opaque_types 필드에 저장됩니다(take_opaque_types_for_query_response 함수가 이를 읽어 냅니다).
query_response_substitution_guess에서 QueryResponse가 주변 infcx로 인스턴스화될 때, (위에서와 같이) handle_opaque_type을 호출하여 각 숨겨진 타입 제약을 변환합니다.
약간 “이상한” 점이 하나 있습니다. 인스턴스화된 불투명 타입에는 순서가 있습니다(한 불투명 타입이 다른 불투명 타입과 비교되어, 숨겨진 타입을 할당받을 불투명 타입 하나를 골라야 하는 경우). “기대되는(expected)” 것으로 간주되는 쪽을 사용합니다. 하지만 실제로는 두 불투명 타입 모두 정의 사용을 가질 수 있습니다. 쿼리 결과가 인스턴스화될 때, 이는 해당 쿼리를 사용하는 컨텍스트에서 다시 평가됩니다. 최종 컨텍스트(함수의 typeck, mir borrowck 또는 wf-check)는 어느 불투명 타입이 실제로 인스턴스화될 수 있는지 알고 있으므로, 이를 올바르게 처리합니다.
MIR 빌림 검사기 내부
MIR 빌림 검사기는 nll_relate를 통해 대상들을 관계 지으며, 영역(region)에만 관심을 둡니다. 어떤 타입 관계든 숨겨진 타입의 바인딩을 유발하므로 빌림 검사기는 타입 검사기와 같은 일을 하지만, 명백히 죽은 코드(예: 패닉 이후)는 무시합니다. 또한 숨겨진 타입에 관해서는 빌림 검사기가 진실의 원천(source of truth)입니다. 숨겨진 타입의 어떤 라이프타임이 불투명 타입 선언의 어떤 라이프타임에 대응하는지 제대로 알아낼 수 있는 것은 빌림 검사기뿐이기 때문입니다.
하위 호환성을 위한 편법
반환 위치의 impl Trait에는 어떤 RFC에도 포함되지 않았고 의도치 않게 안정화되었을 가능성이 높은 여러 특이한 동작이 있습니다. 이를 지원하기 위해 replace_opaque_types_with_inference_vars를 사용하여 이전 동작을 다시 도입합니다.
하위 호환성을 위한 편법은 세 가지입니다.
-
모든 반환 지점은 같은 추론 변수를 공유하므로, 일부 반환 지점은 다른 반환 지점이 구체 타입을 사용하는 경우에만 컴파일될 수 있습니다.
#![allow(unused)] fn main() { fn foo() -> impl Debug { if false { return std::iter::empty().collect(); } vec![42] } } -
impl Trait에 대한 연관 타입 동등성 제약은 숨겨진 타입이 연관 타입의 트레이트 바운드를 만족하는 한 사용할 수 있습니다. 불투명한impl Trait시그니처 자체는 이를 만족하지 않아도 됩니다.#![allow(unused)] fn main() { trait Duh {} impl Duh for i32 {} trait Trait { type Assoc: Duh; } // the fact that `R` is the `::Output` projection on `F` causes // an intermediate inference var to be generated which is then later // compared against the actually found `Assoc` type. impl<R: Duh, F: FnMut() -> R> Trait for F { type Assoc = R; } // The `impl Send` here is then later compared against the inference var // created, causing the inference var to be set to `impl Send` instead of // the hidden type. We already have obligations registered on the inference // var to make it uphold the `: Duh` bound on `Trait::Assoc`. The opaque // type does not implement `Duh`, even if its hidden type does. // Lazy TAIT would error out, but we inserted a hack to make it work again, // keeping backwards compatibility. fn foo() -> impl Trait<Assoc = impl Send> { || 42 } } -
클로저는 부모 함수의
impl Trait에 대한 숨겨진 타입을 만들 수 없습니다. 1번 항목이 추론 변수를 도입하기 때문에 클로저는 언제나 추론 변수만 보게 되므로 이 점은 대체로 문제가 되지 않지만, 1번을 고친다면 이는 문제가 될 것입니다.
트레이트 내 반환 위치 impl 트레이트
트레이트 내 반환 위치 impl 트레이트(RPITIT)는 개념적으로(그리고 #112988 이후로는 실제로도) 트레이트 메서드의 RPIT를 제네릭 연관 타입(GAT)으로 바꾸어 주는 문법적 설탕이며, 사용자가 해당 GAT를 트레이트 쪽이나 impl 쪽에서 직접 정의할 필요가 없습니다.
RPITIT는 원래 트레이트 내 async fn(AFIT) 지원을 추가한 #101224에서 구현되었는데, 이전에 RFC를 거친 AFIT를 구현하는 과정에서 RPITIT의 구현도 자연스럽게 함께 얻어졌기 때문입니다. 이후 RFC 3425에서 별도로 RFC가 작성되었고, 이는 T-lang의 승인을 받았습니다.
어떻게 동작합니까?
이 문서는 대체로 컴파일 파이프라인 순서를 따릅니다:
- AST 로워링(AST -> HIR)
- HIR ty 로워링(HIR -> rustc_middle::ty 데이터 타입)
- typeck
AST 로워링
RPITIT의 AST 로워링은 RPIT의 로워링과 거의 같습니다. 여전히 이들을 hir::ItemKind::OpaqueTy로 로워링합니다. 차이점은 다음 두 가지입니다:
불투명 타입에 대해 in_trait를 기록합니다. 이는 HIR ty 로워링, HIR을 다루는 진단 등에 해당 불투명 타입이 RPITIT임을 알려 줍니다.
아래에서 설명할 불투명 타입의 lifetime_mapping들을 기록합니다.
여담: 불투명 타입의 라이프타임 복제
(RPITIT뿐만 아니라) 모든 불투명 타입은 캡처한 라이프타임을 해당 불투명 타입에 국한된 새로운 라이프타임 매개변수로 복제하게 됩니다. 이렇게 하는 주된 이유는 RPIT가 캡처한 늦은 바인딩(late-bound) 인자를 “구체화(reify)”1할 수 있어야, 즉 이른 바인딩(early-bound) 인자로 만들 수 있어야 하기 때문입니다. 그래야 이들을 불투명 타입의 제네릭 인자로 사용할 수 있고, 나중에 숨겨진 타입을 인스턴스화하는 데에도 사용할 수 있습니다. AST 로워링 중에는 어떤 라이프타임이 이른 바인딩인지 늦은 바인딩인지 알 수 없으므로, 모든 라이프타임에 대해 이 작업을 수행합니다.
RPITIT에서 주로 추가된 점은, 로워링 중에 캡처된 라이프타임과 이에 대응하는 복제된 라이프타임 사이의 관계를 추가 필드인 OpaqueTy::lifetime_mapping에 기록해 추적한다는 것입니다. 이 라이프타임 매핑은 나중에 clauses_of에서 복제된 라이프타임과 원본 라이프타임이 같도록 강제하는 바운드를 설치하는 데 사용되며, 이는 이러한 GAT를 올바르게 타입 검사하기 위한 것입니다. 이에 대해서는 아래에서 다룹니다.
참고
중복 없이 로워링할 수 있다면 더 좋을 수 있으며, 이를 위해서는 조기 바인딩 라이프타임과 후기 바인딩 라이프타임을 구분하지 않아야 한다고 생각합니다. 따라서 제네릭에서 후기 바인딩 라이프타임 고려 #103448과 같은 해결책이 필요하며, 이어서 impl 트레이트에 대해 함수 라이프타임 상속 #103449와 유사한 PR도 필요합니다.
HIR ty 로워링
HIR ty 로워링의 주된 변경 사항은 RPITIT에 대한 hir::TyKind::OpaqueDef를 불투명 타입 대신 프로젝션으로 로워링한다는 것이며, 이때 트레이트 안의 새 연관 타입을 위해 새로 합성한 def-id를 사용합니다. 이 def-id를 정확히 어떻게 얻는지는 다음 절에서 설명합니다.
즉, RPITIT에 대해 lower_ty를 호출할 때마다 불투명 타입 대신 프로젝션을 돌려받게 됩니다. 이 프로젝션은 이후 올바른 값으로 정규화될 수 있습니다. 트레이트 안에 있다면 원래의 불투명 타입으로, impl 안에 있다면 RPITIT의 추론된 타입으로 정규화됩니다.
합성 연관 타입으로 로워링하기
쿼리 피딩을 사용하여, 메서드에 나타나는 RPITIT에 대해 트레이트 쪽과 impl 쪽 모두에 새로운 연관 타입을 합성합니다.
트레이트에서 RPITIT 로워링하기
트레이트의 모든 연관 타입을 수집하기 위해 트레이트에 대해 tcx.associated_item_def_ids(trait_def_id)가 호출되면, 이전에는 이 쿼리가 단순히 트레이트의 자식인 HIR 항목들의 def-id를 반환했습니다. #112988 이후에는 추가로 트레이트의 각 메서드에 대해 tcx.associated_types_for_impl_traits_in_associated_fn(trait_method_def_id)가 반환하는 def-id들을 더합니다. 이 함수는 각 트레이트 메서드를 순회하면서 시그니처에 나타나는 모든 RPITIT를 수집한 다음, 각 RPITIT에 대해 associated_type_for_impl_trait_in_trait를 호출하며, 이 함수가 새로운 연관 타입을 합성합니다.
impl에서 RPITIT 로워링하기
마찬가지로 impl의 HIR 항목들과 함께, 각 impl 메서드에 대해 해당 impl 메서드의 associated_types_for_impl_traits_in_associated_fn을 모두 추가로 더합니다. 이는 associated_type_for_impl_trait_in_impl을 호출하며, 이 함수는 대응하는 트레이트 메서드에서 오는 각 RPITIT에 대해 연관 타입 정의를 합성합니다.
새로운 연관 타입 합성하기
각 RPITIT에 대한 합성 GAT의 새로운 def-id를 합성하기 위해 쿼리 피딩(TyCtxtAt::create_def)을 사용합니다.
로컬에서는 rustc의 쿼리 대부분이 값을 계산하기 위해 항목의 HIR에 대해 패턴 매칭을 합니다. RPITIT에는 실제로 연관된 HIR이 없거나, 적어도 연관 타입에 대응하는 HIR은 없으므로, opt_def_kind, associated_item, visibility, defaultness 같은 많은 쿼리를 즉시 계산하여 피딩해야 합니다.
이러한 쿼리 대부분의 값은 자명합니다. RPITIT가 개념적으로 정보 대부분을 부모 함수로부터 상속하거나(예: visibility), 연관 타입이기 때문에 당연히 알 수 있기 때문입니다(opt_def_kind).
그 밖의 일부 쿼리는 더 복잡하거나 피딩할 수 없으며, 그중 흥미로운 것들을 아래에 설명합니다.
트레이트에 대한 generics_of
RPITIT에 대한 GAT는 개념적으로 그것이 유래한 RPIT와 동일한 제네릭을 상속합니다. 그러나 메서드를 제네릭의 부모로 두는 대신, 트레이트가 부모가 됩니다.
현재는 RPIT의 제네릭과 메서드 제네릭을 가져와 둘 다 하나의 새로운 제네릭 목록으로 평탄화하고, 각 매개변수의 def-id는 그대로 유지하는 방식으로 처리하고 있습니다. (이로 인해 def-id가 잘못된 부모를 가지는 문제가 생길 수 있지만, 최악의 경우라도 진단 문제를 일으키는 정도입니다. 이것이 결국 문제가 된다면, 부모가 GAT인 제네릭 매개변수에 대해 새로운 def-id를 합성할 수 있습니다.)
예시로 살펴보기
#![allow(unused)]
fn main() {
trait Foo {
fn method<'early: 'early, 'late, T>() -> impl Sized + Captures<'early, 'late>;
}
}
이는 다음과 같이 디슈가링됩니다…
#![allow(unused)]
fn main() {
trait Foo {
// vvvvvvvvv method's generics
// vvvvvvvvvvvvvvvvvvvvvvvv opaque's generics
type Gat<'early, T, 'early_duplicated, 'late>: Sized + Captures<'early_duplicated, 'late>;
fn method<'early: 'early, 'late, T>() -> Self::Gat<'early, T, 'early, 'late>;
}
}
impl에 대한 generics_of
impl의 GAT에 대한 제네릭은 조금 더 흥미롭습니다. 이 제네릭은 (트레이트 정의에서 온) RPITIT 자체의 제네릭을 impl 메서드의 제네릭 뒤에 덧붙여 구성됩니다. 이 방식에도 위와 같은 문제가 있어 GAT의 제네릭이 잘못된 부모를 가진 def-id의 매개변수를 갖게 되지만, 이는 진단에서만 문제를 일으킬 것입니다.
새로운 제네릭 def-id를 합성한다면 이 문제도 비슷하게 고칠 수 있지만, 이는 나중에 상위 호환 방식으로 할 수 있으며, 관심 있는 새 기여자가 맡을 수도 있을 것입니다.
opt_rpitit_info
explicit_clauses_of처럼, 일부 쿼리는 즉시 피딩하려고 하면 순환을 일으킬 정보를 계산하는 데 의존합니다. 따라서 RPITIT의 GAT에 대한 올바른 값을 반환하는 일은 clauses_of 프로바이더에 맡깁니다. 이를 위해 쿼리 초반에 opt_rpitit_info를 사용해 연관 타입이 합성된 것인지 감지합니다. 이 쿼리는 연관 타입이 합성된 것이면 Some을 반환합니다.
그러면 explicit_clauses_of 같은 쿼리를 수행하는 동안 다음과 같이 연관 타입이 합성된 것인지 감지할 수 있습니다.
#![allow(unused)]
fn main() {
fn explicit_clauses_of(tcx: TyCtxt<'_>, def_id: LocalDefId) -> ... {
if let Some(rpitit_info) = tcx.opt_rpitit_info(def_id) {
// Do something special for RPITITs...
return ...;
}
// The regular computation which relies on access to the HIR of `def_id`.
}
}
explicit_clauses_of
RPITIT는 트레이트 쪽과 impl 쪽 모두에서, 자신을 정의한 메서드의 술어(predicate)를 복사하는 것으로 시작합니다.
또한 “양방향 outlives” 술어를 설치합니다. 구체적으로, 캡처된 각 조기 바인딩 라이프타임마다 양방향으로 region-outlives 절을 추가하여, 해당 라이프타임이 로워링 결과로 생긴 중복된 조기 바인딩 라이프타임과 같도록 제약합니다. 이는 예제로 보는 것이 가장 이해하기 쉽습니다.
#![allow(unused)]
fn main() {
trait Foo<'a> {
fn bar() -> impl Sized + 'a;
}
// Desugars into...
trait Foo<'a> {
type Gat<'a_duplicated>: Sized + 'a
where
'a: 'a_duplicated,
'a_duplicated: 'a;
//~^ Specifically, we should be able to assume that the
// duplicated `'a_duplicated` lifetime always stays in
// sync with the `'a` lifetime.
fn bar() -> Self::Gat<'a>;
}
}
assumed_wf_types
트레이트와 impl 양쪽의 GAT는 RPITIT를 정의하는 트레이트 메서드의 assumed_wf_types를 상속합니다. 이는 다음 코드가 lowering되었을 때 well-formed임을 보장하기 위한 것입니다.
#![allow(unused)]
fn main() {
trait Foo {
fn iter<'a, T>(x: &'a [T]) -> impl Iterator<Item = &'a T>;
}
// which is lowered to...
trait FooDesugared {
type Iter<'a, T>: Iterator<Item = &'a T>;
//~^ assumed wf: `&'a [T]`
// Without assumed wf types, the GAT would not be well-formed on its own.
fn iter<'a, T>(x: &'a [T]) -> Self::Iter<'a, T>;
}
}
assumed_wf_types는 로컬 def id에 대해서만 정의되므로, RPIT를 가진 외부 트레이트의 impl에 대해 assumed_wf_types를 올바르게 구현하려면 RPITIT의 assumed wf 타입을 extern 쿼리 assumed_wf_types_for_rpitit에 인코딩해야 합니다.
타입 검사
RPITIT 추론 알고리즘
RPITIT 추론 알고리즘은 collect_return_position_impl_trait_in_trait_tys에 구현되어 있습니다.
개요: impl 메서드와 트레이트 메서드가 주어지면, 트레이트 메서드를 가져와 시그니처에 있는 각 RPITIT를 추론 변수로 인스턴스화합니다. 그런 다음 이 트레이트 메서드 시그니처를 impl 메서드 시그니처와 동일시(equate)하고, 그 결과로 생기는 모든 의무(obligation)를 처리하여 메서드 내 모든 RPITIT의 타입을 추론합니다.
이 메서드는 또한 각 RPITIT의 숨겨진 타입이 실제로 impl Trait의 바운드를 만족하는지, 즉 impl Trait = Foo로 추론했다면 Foo: Trait가 성립하는지 확인하는 역할도 맡습니다.
예시는 다음과 같습니다.
#![allow(unused)]
#![feature(return_position_impl_trait_in_trait)]
fn main() {
use std::ops::Deref;
trait Foo {
fn bar() -> impl Deref<Target = impl Sized>;
// ^- RPITIT ?0 ^- RPITIT ?1
}
impl Foo for () {
fn bar() -> Box<String> { Box::new(String::new()) }
}
}
결과적으로 fn() -> ?0 형태의 트레이트 시그니처와, 중첩된 의무 ?0: Deref<Target = ?1>, ?1: Sized를 얻게 됩니다. impl 시그니처는 fn() -> Box<String>입니다.
이 시그니처들을 동일시하면 ?0 = Box<String>를 얻고, 이어서 의무 Box<String>: Deref<Target = ?1>를 처리하면 ?1 = String을 얻으며, 나머지 의무 String: Sized는 참으로 평가됩니다.
알고리즘이 끝나면, 연관 타입 def-id에서 시그니처로부터 추론된 구체 타입으로의 매핑을 얻게 됩니다. 이 매핑은 각 RPITIT에 대해 type Assoc = ...의 = 뒤에 와야 할 타입을 기술하므로, 이 매핑을 사용하여 impl 안의 합성 연관 타입에 대한 type_of를 구현할 수 있습니다.
RPITIT 숨겨진 타입 추론에서의 암시적 바운드
collect_return_position_impl_trait_in_trait_tys는 이행과 영역 해석을 수행하므로, compare_method_clause_entailment와 동일하게 기대되는 암시적 바운드로 영역 의무를 증명할 수 있도록 assumed_wf_types를 제공해야 합니다.
메서드의 반환 타입은 가정된 WF 타입 중 하나로 간주되고, 불투명 타입 추론을 위해 반환 타입을 추론 변수로 즉시 폴딩하므로, 불투명 타입 추론이 끝나면 반환 타입은 RPITIT의 숨겨진 타입을 포함하도록 해석됩니다. 이는 RPITIT의 숨겨진 타입이 적격하다는 것을 독립적으로 증명하지 않은 채 적격하다고 가정된다는 뜻입니다. 이로 인해 미묘한 건전성 버그가 발생했습니다. 이러한 순환 추론을 막기 위해, 메서드 반환 타입에 있는 RPITIT의 숨겨진 타입을 대신 플레이스홀더로 바꾸며, 플레이스홀더로부터는 암시적 적격성 바운드가 생기지 않습니다.
기본 트레이트 본문
다음과 같은 기본 트레이트 본문을 타입 검사하려면 흥미로운 편법이 하나 필요합니다.
#![allow(unused)]
fn main() {
trait Foo {
fn bar() -> impl Sized {
1i32
}
}
}
Foo::bar의 param-env에 프로젝션 술어를 설치하여 RPITIT의 GAT가 RPITIT의 불투명 타입으로 정규화된다고 가정할 수 있게 해야 합니다. 이는 트레이트 메서드와 RPITIT의 GAT가 항상 “동기화“되어 있다는 관찰에 기반합니다. 즉, 둘 중 하나는 다른 하나도 재정의될 때에만 재정의됩니다.
이를 위 코드를 비슷하게 디슈가링한 다음 코드와 비교해 보십시오. 이 코드는 같은 가정에 의존할 수 없으므로 실패합니다.
#![allow(unused)]
#![feature(impl_trait_in_assoc_type)]
#![feature(associated_type_defaults)]
fn main() {
trait Foo {
type RPITIT = impl Sized;
fn bar() -> Self::RPITIT {
01i32
}
}
}
다운스트림 impl이 bar의 구현을 제공하지 않고도 이론적으로 RPITIT의 구현을 제공할 수 있기 때문에 실패합니다:
error[E0308]: mismatched types
--> src/lib.rs:8:9
|
5 | type RPITIT = impl Sized;
| ------------------------- associated type defaults can't be assumed inside the trait defining them
6 |
7 | fn bar() -> Self::RPITIT {
| ------------ expected `<Self as Foo>::RPITIT` because of return type
8 | 01i32
| ^^^^^ expected associated type, found `i32`
|
= note: expected associated type `<Self as Foo>::RPITIT`
found type `i32`
적격성 검사
RPITIT의 적격성은 일반 연관 타입과 똑같이 검사합니다.
clauses_of에 중복된 조기 바인딩 라이프타임을 원래 라이프타임과 연결하는 라이프타임 바운드를 추가했고, RPITIT가 비롯된 메서드의 WF 타입을 상속하는 assumed_wf_types를 구현했으므로(#113704), GAT를 일반 GAT처럼 WF 검사해도 문제가 없습니다.
망가진 것, 이상한 것 등
특수화는 심각하게 망가져 있습니다
위에서 설명한 “기본 트레이트 메서드“는 특수화와 잘 상호 작용하지 않습니다. 해당 프로젝션 바운드를 트레이트 기본 메서드에만 설치하고 impl 메서드에는 설치하지 않기 때문입니다. 특수화는 이미 상당히 망가져 있으므로 자세히 설명하지는 않겠지만, 이는 현재 다음에서 추적 중인 버그입니다: * tests/ui/impl-trait/in-trait/specialization-broken.rs
프로젝션에는 변성이 없습니다
이 코드는 프로젝션에 변성이 없기 때문에 실패합니다:
#![allow(unused)]
#![feature(return_position_impl_trait_in_trait)]
fn main() {
trait Foo {
// Note that the RPITIT below does *not* capture `'lt`.
fn bar<'lt: 'lt>() -> impl Eq;
}
fn test<'a, 'b, T: Foo>() -> bool {
<T as Foo>::bar::<'a>() == <T as Foo>::bar::<'b>()
//~^ ERROR
// (requires that `'a == 'b`)
}
}
이는 <T as Foo>::Rpitit<'a>와 <T as Foo>::Rpitit<'b>가 라이프타임을 캡처하지 않더라도 둘을 서로 관련지을 수 없기 때문입니다. 일반 불투명 타입을 사용했다면 해당 라이프타임 매개변수에 대해 쌍변(bivariant)이므로 이 코드는 동작했을 것입니다:
#![allow(unused)]
#![feature(return_position_impl_trait_in_trait)]
fn main() {
fn bar<'lt: 'lt>() -> impl Eq {
()
}
fn test<'a, 'b>() -> bool {
bar::<'a>() == bar::<'b>()
}
}
하지만 RPITIT의 캡처 동작은 어차피 스코프 내의 모든 라이프타임을 캡처하도록 변경될 가능성이 높으므로, 이는 아마 괜찮을 것입니다. 프로젝션을 관련지을 때 RPITIT의 변성을 고려하도록 한다면, 나중에 상위 호환되는 방식으로 이 제약을 완화할 수도 있습니다.
-
이는 compiler-errors(rustc 컴파일러 기여자)식 용어이며, 정확하다고 주장하는 것은 아닙니다 :^) ↩
불투명 타입 영역 추론 제약
이 장에서는 불투명 타입의 숨겨진 타입 Opaque<'a, 'b, .., A, B, ..> := SomeHiddenType을 정의할 때 불투명 타입의 제네릭 인자에 부과하는 여러 제약을 다룹니다.
빌림 검사는 불투명 타입 추론의 마지막 단계이므로, 이러한 제약은 빌림 검사(소스)에서 구현됩니다.
배경: 타입 및 상수 제네릭 인자
타입 인자에는 두 가지 제약이 필요합니다. 각 타입 인자는 (1) 타입 매개변수여야 하고, (2) 제네릭 인자 중에서 고유해야 합니다. 상수 인자에도 동일하게 적용됩니다.
경우 (1)의 예시:
#![allow(unused)]
fn main() {
type Opaque<X> = impl Sized;
// `T` is a type parameter.
// Opaque<T> := ();
fn good<T>() -> Opaque<T> {}
// `()` is not a type parameter.
// Opaque<()> := ();
fn bad() -> Opaque<()> {} //~ ERROR
}
경우 (2)의 예시:
#![allow(unused)]
fn main() {
type Opaque<X, Y> = impl Sized;
// `T` and `U` are unique in the generic args.
// Opaque<T, U> := T;
fn good<T, U>(t: T, _u: U) -> Opaque<T, U> { t }
// `T` appears twice in the generic args.
// Opaque<T, T> := T;
fn bad<T>(t: T) -> Opaque<T, T> { t } //~ ERROR
}
동기: 첫 번째 경우인 Opaque<()> := ()에서는 숨겨진 타입이 Opaque<X> := X와 Opaque<X> := ()라는 서로 다른 두 해석과 모두 호환되므로 모호합니다. 마찬가지로 두 번째 경우인 Opaque<T, T> := T도 Opaque<X, Y> := X로 해석해야 하는지 Opaque<X, Y> := Y로 해석해야 하는지 모호합니다. 이러한 모호성 때문에 두 경우 모두 유효하지 않은 정의 사용으로 거부됩니다.
고유성 제약
각 라이프타임 인자는 인자 목록 안에서 고유해야 하며 'static이어서는 안 됩니다. 이는 타입 매개변수의 경우와 비슷하게, 숨겨진 타입 추론에서 발생하는 모호성을 피하기 위함입니다. 예를 들어, 아래의 유효하지 않은 정의 사용 Opaque<'static> := Inv<'static>은 Opaque<'x> := Inv<'static>과 Opaque<'x> := Inv<'x> 모두와 호환됩니다.
#![allow(unused)]
fn main() {
type Opaque<'x> = impl Sized + 'x;
type Inv<'a> = Option<*mut &'a ()>;
fn good<'a>() -> Opaque<'a> { Inv::<'static>::None }
fn bad() -> Opaque<'static> { Inv::<'static>::None }
//~^ ERROR
}
#![allow(unused)]
fn main() {
type Opaque<'x, 'y> = impl Trait<'x, 'y>;
fn good<'a, 'b>() -> Opaque<'a, 'b> {}
fn bad<'a>() -> Opaque<'a, 'a> {}
//~^ ERROR
}
의미론적 라이프타임 동등성: 타입 매개변수와 비교했을 때 라이프타임이 갖는 한 가지 복잡한 점은 구문상 서로 다른 두 라이프타임이 의미상으로는 같을 수 있다는 것입니다. 따라서 라이프타임이 고유한지 검증할 때 주의해야 합니다.
#![allow(unused)]
fn main() {
// This is also invalid because `'a` is *semantically* equal to `'static`.
fn still_bad_1<'a: 'static>() -> Opaque<'a> {}
//~^ Should error!
// This is also invalid because `'a` and `'b` are *semantically* equal.
fn still_bad_2<'a: 'b, 'b: 'a>() -> Opaque<'a, 'b> {}
//~^ Should error!
}
고유성 규칙의 예외
위 고유성 규칙의 예외는 불투명 타입 정의에 있는 경계가 어떤 라이프타임 매개변수를 다른 라이프타임 매개변수 또는 'static 라이프타임과 같도록 요구하는 경우입니다.
#![allow(unused)]
fn main() {
// The definition requires `'x` to be equal to `'static`.
type Opaque<'x: 'static> = impl Sized + 'x;
fn good() -> Opaque<'static> {}
}
동기: RPIT에 고유성 제약을 구현하려던 시도는 crater에서 발견된 호환성 깨짐으로 이어졌습니다. 규칙에 대한 이 예외를 통해 이 문제를 완화할 수 있습니다. 이 예외가 없다면 깨졌을 코드의 예시:
#![allow(unused)]
fn main() {
struct Type<'a>(&'a ());
impl<'a> Type<'a> {
// `'b == 'a`
fn do_stuff<'b: 'a>(&'b self) -> impl Trait<'a, 'b> {}
}
}
이것이 올바른 이유: Opaque<'a, 'a> := &'a str과 같은 정의 사용은 Opaque<'x, 'y> := &'x str으로도, Opaque<'x, 'y> := &'y str으로도 해석될 수 있지만, 적격성(well-formedness) 규칙에 따라 Opaque의 모든 사용이 두 매개변수가 같음을 보장하므로 어느 쪽으로 해석하든 상관없습니다.
보편 라이프타임 제약
불투명 타입 인자에는 보편 양화된(universally quantified) 라이프타임만 허용됩니다. 여기에는 라이프타임 매개변수와 플레이스홀더가 포함됩니다.
#![allow(unused)]
fn main() {
type Opaque<'x> = impl Sized + 'x;
fn test<'a>() -> Opaque<'a> {
// `Opaque<'empty> := ()`
let _: Opaque<'_> = ();
//~^ ERROR
}
}
동기: 이 제약으로 라이프타임 인자와 타입 인자가 일관되게 동작하게 되지만, 이는 부수적인 이점일 뿐입니다. 이 제약의 진짜 이유는 순전히 기술적인 것으로, 멤버 제약 알고리즘에 근본적인 한계가 있기 때문입니다. 불투명 타입 정의 Opaque<'?1> := &'?2 u8을 만나면 멤버 제약 '?2 member-of ['static, '?1]이 등록됩니다. 알고리즘이 올바른 선택을 하려면, 선택 영역 ['static, '?1] 사이의 “outlives” 관계의 완전한 집합이 영역 추론을 수행하기 전에 이미 알려져 있어야 합니다. 이는 각 선택 영역이 다음 중 하나인 경우에만 충족될 수 있습니다:
- 보편 영역, 즉
RegionKind::Re{EarlyParam,LateParam,Placeholder,Static}입니다. 보편 영역 간의 관계는 영역 추론 이전에 명시적 바운드와 암시적 바운드로부터 완전히 알려져 있기 때문입니다. - 또는 보편 영역과 “엄격하게 동일한” 존재 영역입니다. 엄격한 라이프타임 동일성은 아래에서 정의하며, 전체 영역 추론 이전에 평가할 수 있는 유일한 종류의 동일성이기 때문에 여기에서 필요합니다.
엄격한 라이프타임 동일성: 두 라이프타임 사이에 양방향 outlives 제약이 있으면 두 라이프타임이 엄격하게 동일하다고 합니다. NLL 용어로 말하면, 이는 두 라이프타임이 같은 SCC에 속한다는 뜻입니다. 중요한 점은 이 종류의 동일성은 전체 영역 추론 이전에(물론 제약 수집 이후에) 평가할 수 있다는 것입니다. 다른 종류의 동일성은 두 라이프타임 변수가 엄격하게 동일하지 않더라도 영역 추론 결과 같은 값을 갖게 되는 경우입니다. 예전에 이 차이를 어떻게 혼동했는지는 #113971을 참고하십시오.
“once modulo regions” 제약과의 상호작용 위 예제에서 시그니처의 불투명 타입은 Opaque<'a>이고 잘못된 정의 사용의 불투명 타입은 Opaque<'empty>라는 점에 유의하십시오. 제안된 MiniTAIT 계획, 즉 “once modulo regions” 규칙에서는 이미 이를 허용하지 않습니다. “보편 라이프타임” 제약은 “MiniTAIT” 제약으로부터 논리적으로 도출되므로 불필요해 보일 수도 있지만, 뒤이어 나오는 라이프타임 동일성과 클로저에 관한 논의는 여전히 유효합니다.
클로저 제약
불투명 타입이 클로저/코루틴/인라인 const 본문 안에서 정의될 때, 클로저에 대해 “외부“인 보편 라이프타임은 불투명 타입 인자로 허용되지 않습니다. 외부 영역은 RegionClassification::External에 정의되어 있습니다.
예제: (이 예제는 현재 nightly에서 우연히 컴파일되지만, 더 실용적인 예제들은 이미 혼란스러운 오류와 함께 거부됩니다.)
#![allow(unused)]
fn main() {
type Opaque<'x> = impl Sized + 'x;
fn test<'a>() -> Opaque<'a> {
let _ = || {
// `'a` is external to the closure
let _: Opaque<'a> = ();
//~^ Should be an error!
};
()
}
}
동기: 클로저 본문에서 외부 라이프타임은 “보편” 라이프타임으로 분류되지만, 서로 간의 관계를 미리 알 수 없다는 점에서 오히려 존재 라이프타임처럼 동작합니다. 그 대신 값은 존재 라이프타임과 똑같이 추론되며, 요구 사항은 부모 fn으로 다시 전파됩니다. 이는 앞서 설명한 멤버 제약 알고리즘을 깨뜨립니다:
알고리즘이 올바른 선택지를 고르려면, 영역 추론을 수행하기 전에 선택지 영역
['static, '?1]사이의 “outlives” 관계 전체가 이미 알려져 있어야 합니다.
다음은 그 과정을 자세히 보여 주는 예제입니다:
#![allow(unused)]
fn main() {
type Opaque<'x, 'y> = impl Sized;
//
fn test<'a, 'b>(s: &'a str) -> impl FnOnce() -> Opaque<'a, 'b> {
move || { s }
//~^ ERROR hidden type for `Opaque<'_, '_>` captures lifetime that does not appear in bounds
}
// The above closure body is desugared into something like:
fn test::{closure#0}(_upvar: &'?8 str) -> Opaque<'?6, '?7> {
return _upvar
}
// where `['?8, '?6, ?7]` are universal lifetimes *external* to the closure.
// There are no known relations between them *inside* the closure.
// But in the parent fn it is known that `'?6: '?8`.
//
// When encountering an opaque definition `Opaque<'?6, '?7> := &'8 str`,
// The member constraints algorithm does not know enough to safely make `?8 = '?6`.
// For this reason, it errors with a sensible message:
// "hidden type captures lifetime that does not appear in bounds".
}
이러한 제약이 없으면 오류 메시지가 혼란스러워지며, 더 중요하게는 클로저에서 멤버 제약이 심각하게 망가져 있기 때문에 향후 깨질 가능성이 높은 코드를 받아들이게 될 위험이 있습니다.
출력 타입: 실제 코드에서 이 문제가 가장 흔하게 발생하는 경우는 클로저/async 블록의 출력 타입이라고 생각합니다. 클로저와 async 블록 사이에는 이 문제를 더욱 잘 보여 주는 불일치가 있다는 점도 주목할 만하며, 이는 future에만 적용되는 replace_opaque_types_with_inference_vars 핵 때문입니다.
#![allow(unused)]
fn main() {
type Opaque<'x> = impl Sized + 'x;
fn test<'a>() -> impl FnOnce() -> Opaque<'a> {
// Output type of the closure is Opaque<'a>
// -> hidden type definition happens *inside* the closure
// -> rejected.
move || {}
//~^ ERROR expected generic lifetime parameter, found `'_`
}
}
#![allow(unused)]
fn main() {
use std::future::Future;
type Opaque<'x> = impl Sized + 'x;
fn test<'a>() -> impl Future<Output = Opaque<'a>> {
// Output type of the async block is unit `()`
// -> hidden type definition happens in the parent fn
// -> accepted.
async move {}
}
}
이펙트, const 트레이트, const 조건 검사
HostEffect 술어
HostEffectPredicate는 [const] Tr 또는 const Tr 바운드에서 비롯되는 술어의 일종입니다. 이 술어는 트레이트 참조와 constness를 가지며, constness는 바운드에 따라 Maybe 또는 Const가 될 수 있습니다. [const] Tr, 더 정확히 말하면 Maybe 바운드는 어떤 컨텍스트에 있느냐에 따라 다르게 적용되므로 일반 바운드와는 다르게 동작합니다. T: Tr와 같은 함수의 일반 트레이트 바운드는 함수가 호출될 때 증명되고 함수 내부에서는 가정되도록 clauses_of 쿼리 안에 수집되는 반면, T: [const] Tr와 같은 바운드는 일반 트레이트 바운드처럼 동작하여 clauses_of의 결과에 T: Tr를 추가할 뿐만 아니라 const_conditions 쿼리에도 HostEffectPredicate를 추가합니다.
반면 T: const Tr 바운드는 컨텍스트에 따라 의미가 바뀌지 않으므로, HostEffect(T: Tr, const)가 const_conditions가 아닌 clauses_of에 추가됩니다.
const_conditions 쿼리
clauses_of는 아이템을 사용하기 위해 증명해야 하는 절(clause)의 집합을 나타냅니다. 예를 들어, 아래 예제에서 foo를 사용하려면 다음과 같습니다:
#![allow(unused)]
fn main() {
fn foo<T>() where T: Default {}
}
T가 Default를 구현한다는 것을 증명할 수 있어야 합니다. 이와 비슷하게, const_conditions는 const 컨텍스트에서 아이템을 사용하기 위해 증명해야 하는 술어의 집합을 나타냅니다. 위 예제를 const 트레이트 바운드를 사용하도록 수정하면 다음과 같습니다:
#![allow(unused)]
fn main() {
const fn foo<T>() where T: [const] Default {}
}
그러면 foo는 const_conditions 쿼리에서 HostEffect(T: Default, maybe)를 얻게 되며, 이는 const 컨텍스트에서 foo를 호출하려면 T가 Default의 const 구현을 가지고 있음을 증명해야 한다는 뜻입니다.
const_conditions의 강제
const_conditions는 현재 여러 곳에서 검사됩니다.
const 컨텍스트(const fn과 const 아이템 포함)에서 이루어지는 HIR의 모든 호출은 호출 대상 함수의 const_conditions가 성립하는지 검사합니다. 이 작업은 FnCtxt::enforce_context_effects에서 수행됩니다. 다음 코드가 컴파일되어야 하므로, 함수가 참조되기만 하고 호출되지 않는 경우에는 검사하지 않는다는 점에 유의하십시오:
#![allow(unused)]
fn main() {
const fn hi<T: [const] Default>() -> T {
T::default()
}
const X: fn() -> u32 = hi::<u32>;
}
트레이트 impl이 적격하려면, impl의 환경으로부터 트레이트의 const_conditions를 증명할 수 있어야 합니다. 이는 wfcheck::check_impl에서 검사됩니다.
다음은 예제입니다:
#![allow(unused)]
fn main() {
const trait Bar {}
const trait Foo: [const] Bar {}
// `const_conditions` contains `HostEffect(Self: Bar, maybe)`
impl const Bar for () {}
impl const Foo for () {}
// ^ here we check `const_conditions` for the impl to be well-formed
}
트레이트 impl의 메서드는 자신이 구현하는 트레이트 메서드보다 더 엄격한 바운드를 가져서는 안 됩니다. 메서드들이 호환되는지 검사하기 위해 impl의 술어와 트레이트 메서드의 술어를 합친 혼합 환경을 구성하고, 그 환경에서 impl 메서드의 술어를 증명하려고 시도합니다. const_conditions에 대해서도 같은 작업을 수행합니다:
#![allow(unused)]
fn main() {
const trait Foo {
fn hi<T: [const] Default>();
}
impl<T: [const] Clone> Foo for Vec<T> {
fn hi<T: [const] PartialEq>();
// ^ we can't prove `T: [const] PartialEq` given `T: [const] Clone` and
// `T: [const] Default`, therefore we know that the method on the impl
// is stricter than the method on the trait.
}
}
이러한 검사는 compare_method_clause_entailment에서 수행됩니다. 연관 타입에 대해 같은 검사를 수행하는 유사한 함수는 compare_type_clause_entailment입니다. 두 함수 모두 const 컨텍스트에서는 const_conditions를 고려해야 합니다.
MIR에서는 const 검사의 일부로, 호출되는 아이템의 const_conditions가 Checker::revalidate_conditional_constness에서 다시 검증됩니다.
연관 타입과 트레이트에 대한 explicit_implied_const_bounds
다음과 같은 연관 타입, 불투명 타입, 슈퍼트레이트에 대한 바운드는 다르게 표현됩니다:
#![allow(unused)]
fn main() {
trait Foo: [const] PartialEq {
type X: [const] PartialEq;
}
fn foo() -> impl [const] PartialEq {
// ^ unimplemented syntax
}
}
호출자에 대해 증명되어야 하고 정의 내부에서는 가정할 수 있는 const_conditions(예: 함수의 트레이트 바운드)와 달리, 이 바운드들은 정의 시점(impl에서, 또는 불투명 타입을 반환할 때)에 증명되어야 하지만 호출자 측에서는 가정할 수 있습니다. 이 바운드들에 대응하는 non-const 바운드는 explicit_item_bounds라고 부릅니다.
이 바운드들은 HIR 타입 검사에서는 compare_impl_item::check_type_bounds에서, 기존 솔버에서는 evaluate_host_effect_from_item_bounds에서, 새 솔버에서는 consider_additional_alias_assumptions에서 검사됩니다.
HostEffectPredicate 증명하기
HostEffectPredicate는 기존 솔버와 새 트레이트 솔버 모두에 구현되어 있습니다. 일반적으로 다음 조건 중 하나가 충족되면 HostEffect 술어를 증명할 수 있습니다.
- 호출자 바운드로부터 술어를 가정할 수 있는 경우,
- 해당 타입이 그 트레이트에 대한
constimpl을 가지고 있고, 그리고 그 impl의 const 조건이 성립하며, 그리고 트레이트의explicit_implied_const_bounds가 성립하는 경우, 또는 - 해당 타입이 const 컨텍스트에서 그 트레이트에 대한 내장 구현을 가지고 있는 경우입니다. 예를 들어, 함수 항목은 const 조건이 충족되면
Fn을 구현할 수 있으며, 타입이 컴파일 시점에 드롭될 수 있으면 const 컨텍스트에서Destruct가 구현됩니다.
const 트레이트에 대한 추가 내용
추후 보완될 예정입니다.
#[rustc_non_const_trait_method] 속성
이 속성은 내부(표준 라이브러리) 용도로만 사용하도록 만들어졌습니다. 트레이트 메서드에 이 속성을 적용하면, 컴파일러는 이 메서드의 기본 본문이 컴파일 시점에 실행될 수 있는지 검사하지 않습니다. 또한 트레이트 사용자는 const 컨텍스트에서 이 트레이트 메서드를 사용할 수 없습니다. 이 속성은 주로 Iterator와 같은 큰 트레이트를 const화할 때, 그 모든 메서드를 한꺼번에 const로 만들지 않아도 되도록 하는 데 사용됩니다.
트레이트를 const로 안정화할 때는 이 속성이 남아 있어서는 안 됩니다.
패턴 및 완전성 검사
Rust에서 패턴 매칭과 바인딩에는 매우 유용한 몇 가지 속성이 있습니다. 컴파일러는 바인딩이 만들어질 때 반박 불가능한지, 그리고 match 갈래가 완전한지 검사합니다.
패턴 유용성
유용성 검사가 답하는 핵심 질문은 “이 match 표현식에서 저 분기는 중복되는가?“입니다. 더 정확히 말하면, 이 질문은 이미 살펴본 패턴 목록이 주어졌을 때 주어진 새 패턴이 새로운 값과 일치할 수 있는지를 계산하는 문제로 귀결됩니다.
예를 들어, 다음 match 표현식에서는 각 패턴이 그 위의 패턴들과 일치하지 않았던 무언가와 일치할 수 있는지를 차례로 묻습니다. 여기서 4번째 패턴은 1번째 패턴과 중복됨을 알 수 있으며, 해당 분기에는 “unreachable” 경고가 발생합니다. 3번째 패턴은 Foo에 Bar 이외의 다른 배리언트가 있는지에 따라 유용할 수도 있고 그렇지 않을 수도 있습니다. 마지막으로, 와일드카드 패턴(_)이 해당 match의 모든 패턴 목록에 대해 유용한지를 물음으로써 match 전체가 완전한지 물을 수 있습니다. 여기서 _가 유용함을 알 수 있으며((false, None)을 잡아낼 것이므로), 따라서 이 표현식에는 “non-exhaustive match” 오류가 발생합니다.
#![allow(unused)]
fn main() {
// x: (bool, Option<Foo>)
match x {
(true, _) => {} // 1
(false, Some(Foo::Bar)) => {} // 2
(false, Some(_)) => {} // 3
(true, None) => {} // 4
}
}
이처럼 유용성은 두 가지 목적으로 사용됩니다. 하나는 도달할 수 없는 코드를 탐지하는 것(사용자에게 유용함)이고, 다른 하나는 match가 완전함을 보장하는 것(match 표현식은 값을 반환할 수 있으므로 건전성을 위해 중요함)입니다.
수행되는 위치
이 검사는 패턴을 작성할 수 있는 모든 곳에서 수행됩니다. match 표현식, if let, let else, 일반 let, 그리고 함수 인자가 이에 해당합니다.
#![allow(unused)]
fn main() {
// `match`
// Usefulness can detect unreachable branches and forbid non-exhaustive matches.
match foo() {
Ok(x) => x,
Err(_) => panic!(),
}
// `if let`
// Usefulness can detect unreachable branches.
if let Some(x) = foo() {
// ...
}
// `while let`
// Usefulness can detect infinite loops and dead loops.
while let Some(x) = it.next() {
// ...
}
// Destructuring `let`
// Usefulness can forbid non-exhaustive patterns.
let Foo::Bar(x, y) = foo();
// Destructuring function arguments
// Usefulness can forbid non-exhaustive patterns.
fn foo(Foo { x, y }: Foo) {
// ...
}
}
알고리즘
완전성 검사는 check_match에서 MIR 구축 전에 실행됩니다. 이 검사는 rustc_pattern_analysis 크레이트에 구현되어 있으며, 알고리즘의 핵심은 usefulness 모듈에 있습니다. 해당 파일에는 알고리즘에 대한 자세한 설명이 담겨 있습니다.
중요한 개념
생성자와 필드
값 Pair(Some(0), true)에서 Pair는 이 값의 생성자라고 하며, Some(0)과 true는 이 값의 필드입니다. 매칭 가능한 모든 값은 이러한 방식으로 분해할 수 있습니다. 생성자의 예로는 Some, None, (,)(2-튜플 생성자), Foo {..}(구조체 Foo의 생성자), 2(숫자 2의 생성자)가 있습니다.
각 생성자는 고정된 개수의 필드를 받으며, 이를 생성자의 항수(arity)라고 합니다. Pair와 (,)의 항수는 2이고, Some의 항수는 1이며, None과 42의 항수는 0입니다. 각 타입에는 알려진 생성자 집합이 있습니다. 어떤 타입은 생성자가 많거나(u64처럼) 심지어 무한히 많습니다(&str과 &[T]처럼).
패턴도 이와 비슷합니다. Pair(Some(_), _)는 생성자 Pair와 두 개의 필드를 가집니다. 차이점은 패턴에만 있는 추가 생성자들이 있다는 것입니다. 바로 와일드카드 _, 변수 바인딩, 0..=10 같은 정수 범위, [_, .., _] 같은 가변 길이 슬라이스입니다. or-패턴은 별도로 다룹니다.
이제 값 v가 패턴 p와 일치하는지 검사하려면, v의 생성자가 p의 생성자와 일치하는지 확인한 다음 필요하면 각 필드를 재귀적으로 비교합니다. 대표적인 예시 몇 가지는 다음과 같습니다.
matches!(v, _) := truematches!((v0, v1), (p0, p1)) := matches!(v0, p0) && matches!(v1, p1)matches!(Foo { a: v0, b: v1 }, Foo { a: p0, b: p1 }) := matches!(v0, p0) && matches!(v1, p1)matches!(Ok(v0), Ok(p0)) := matches!(v0, p0)matches!(Ok(v0), Err(p0)) := false(호환되지 않는 배리언트)matches!(v, 1..=100) := matches!(v, 1) || ... || matches!(v, 100)matches!([v0], [p0, .., p1]) := false(호환되지 않는 길이)matches!([v0, v1, v2], [p0, .., p1]) := matches!(v0, p0) && matches!(v2, p1)
이 개념은 패턴 분석의 절대적인 핵심입니다. constructor 모듈은 생성자를 추출하고, 나열하고, 조작하는 함수를 제공합니다. 이 개념은 충분히 유용해서 match 표현식의 MIR 로워링이나 일부 clippy 린트처럼 컴파일러의 다른 곳에서도 그 변형을 찾아볼 수 있습니다.
생성자 묶기와 분할
패턴 전용 생성자(_, 범위, 변수 길이 슬라이스)는 각각 일반 생성자의 집합을 나타냅니다. 예를 들어 _: Option<T>는 집합 {None, Some}을 나타내고, [_, .., _]는 인자 개수가 2 이상인 슬라이스 생성자의 무한 집합 {[,], [,,], [,,,], …}을 나타냅니다.
이러한 생성자를 관리하기 위해 우리는 생성자를 가능한 한 묶어서 유지합니다. 예를 들면 다음과 같습니다.
#![allow(unused)]
fn main() {
match (0, false) {
(0 ..=100, true) => {}
(50..=150, false) => {}
(0 ..=200, _) => {}
}
}
이 예시에서 0, 1, .., 49는 모두 같은 갈래에 매칭되므로 하나의 묶음으로 취급할 수 있습니다. 실제로 이 match에서 고려해야 할 범위는 0..50, 50..=100, 101..=150,151..=200, 201..뿐입니다. 마찬가지로 다음과 같습니다.
#![allow(unused)]
fn main() {
enum Direction { North, South, East, West }
let wind = (Direction::North, 0u8);
match wind {
(Direction::North, 50..) => {}
(_, _) => {}
}
}
여기서는 North가 아닌 모든 생성자를 하나의 묶음으로 취급할 수 있으므로, 처리해야 할 경우는 North와 그 밖의 모든 것, 이렇게 두 가지뿐입니다.
이를 “생성자 분할“이라고 하며, 완전성 검사가 적절한 시간 안에 실행되도록 하는 데 매우 중요합니다.
빈 타입이 있을 때의 유용성과 도달 가능성
이는 아마도 완전성 검사에서 가장 미묘한 측면일 것입니다. 정확히 말하면, match는 값이 아니라 장소(place)에 대해 동작합니다. 특정한 unsafe 상황에서는 장소가 자신의 타입에 맞는 유효한 데이터를 담고 있지 않을 수 있습니다. 이는 빈 타입에 미묘한 영향을 미칩니다. 다음을 살펴보십시오.
#![allow(unused)]
fn main() {
enum Void {}
let x: u8 = 0;
let ptr: *const Void = &x as *const u8 as *const Void;
unsafe {
match *ptr {
_ => println!("Reachable!"),
}
}
}
이 예시에서 ptr은 유효하지 않은 데이터가 있는 장소를 가리키는 유효한 포인터입니다. _ 패턴은 장소 *ptr의 내용을 살펴보지 않으므로, 이 코드는 문제가 없으며 해당 갈래가 선택됩니다. 다시 말해, 검사하는 장소의 타입이 Void임에도 도달 가능한 갈래가 존재합니다. 하지만 갈래에 바인딩이 있다면 다음과 같습니다.
#![allow(unused)]
fn main() {
#[derive(Copy, Clone)]
enum Void {}
let x: u8 = 0;
let ptr: *const Void = &x as *const u8 as *const Void;
unsafe {
match *ptr {
_a => println!("Unreachable!"),
}
}
}
여기서 바인딩은 *ptr 플레이스에서 Void 타입의 값을 읽어 옵니다. 이 예제에서는 데이터가 유효하지 않으므로 UB가 발생합니다. 일반적인 경우에는 이것이 *ptr에 있는 데이터가 유효하다고 단정합니다. 어느 쪽이든 이 갈래는 결코 선택되지 않습니다.
마지막으로 빈 match인 match *ptr {}를 살펴봅시다. 이것을 완전하다고 간주한다면 *ptr에 유효하지 않은 데이터가 있는 것 자체가 유효하지 않습니다. 다시 말해, 빈 match는 의미상 _a => ... match와 동등합니다. 명시성을 위해 저희는 갈래가 있는 쪽을 선호하므로, 사용자에게 _a 갈래를 제거하라고 알리지 않습니다. 다시 말해, _a 갈래는 도달할 수 없지만 중복된 것은 아닙니다. 이 때문에 린트 메시지에는 “unreachable“이라고 표시되지만, 저희는 도달할 수 없는 갈래가 아니라 중복된 갈래에 대해 린트를 냅니다.
이러한 고려 사항은 특정 플레이스, 즉 UB 없이 유효하지 않은 데이터를 담을 수 있는 플레이스에만 영향을 미칩니다. 해당 플레이스는 포인터 역참조, 참조 역참조, 그리고 union 필드 접근입니다. 저희는 완전성 검사 중에 주어진 플레이스가 유효한 데이터를 담고 있다고 알려져 있는지를 추적합니다.
그렇기는 하지만, 현재의 완전성 검사 구현은 위의 고려 사항을 따르지 않습니다. 안정 버전에서는 빈 타입이 대부분 비어 있지 않은 타입으로 취급됩니다. exhaustive_patterns 기능은 반대쪽으로 잘못을 범합니다. 즉, unsafe 상황에서 도달할 수도 있는 갈래를 생략하도록 허용합니다. never_patterns 실험적 기능은 이를 바로잡아 패턴에서 빈 타입이 올바르게 동작하도록 하는 것을 목표로 합니다.
안전하지 않은 코드 검사
Rust의 특정 표현식은 메모리 안전성을 위반할 수 있으므로 unsafe 블록이나 함수 안에 있어야 합니다. 또한 컴파일러는 대응하는 안전하지 않은 연산이 없는데 unsafe 블록이 사용되면 경고합니다.
개요
안전하지 않은 코드 검사는 check_unsafety 모듈에 있습니다. 이 검사는 함수와 그 함수의 모든 클로저 및 인라인 상수의 THIR를 순회합니다. 이 검사는 unsafe 컨텍스트, 즉 unsafe 블록에 진입했는지 여부를 추적합니다. 안전하지 않은 연산이 unsafe 블록 밖에서 사용되면 오류가 보고됩니다. 안전하지 않은 연산이 unsafe 블록 안에서 사용되면, 해당 블록은 unused_unsafe 린트를 위해 사용된 것으로 표시됩니다.
안전하지 않은 코드 검사에는 타입 정보가 필요하므로, typeck 결과를 활용하는 HIR, THIR 또는 MIR에서 수행될 수 있습니다. THIR를 선택한 이유는 HIR보다 고려해야 할 경우가 적기 때문입니다. 예를 들어 THIR에서는 안전하지 않은 함수 호출과 안전하지 않은 메서드 호출이 동일한 표현을 가집니다. 안전성 검사는 제어 흐름에 의존하지 않아 MIR을 사용할 필요가 없으므로, 이 검사는 MIR에서 수행되지 않습니다. 또한 MIR은 일부 표현식에 대해 충분히 정확한 스팬을 가지고 있지 않습니다.
대부분의 안전하지 않은 연산은 THIR의 ExprKind를 확인하고 인자의 타입을 확인하여 식별할 수 있습니다. 예를 들어 원시 포인터의 역참조는 원시 포인터 타입의 인자를 가진 ExprKind::Deref에 해당합니다.
공용체(union)의 필드에 쓰는 것은 안전하기 때문에, 안전하지 않은 Union 필드 접근을 찾는 일은 조금 더 복잡합니다. 검사기는 대입 표현식의 좌변을 방문하고 있는 시점을 추적하여 그곳에는 공용체 필드가 직접 나타나는 것을 허용하고, 그 외의 모든 경우에는 오류를 발생시킵니다. 공용체 필드 접근은 패턴에서도 발생할 수 있으므로 패턴도 순회해야 합니다.
unused_unsafe 린트
unused_unsafe 린트는 제거할 수 있는 unsafe 블록을 보고합니다. 안전하지 않은 코드 검사기는 unsafe가 필요한 연산을 발견할 때마다 이를 기록합니다. 그런 다음 다음 중 하나에 해당하면 린트가 보고됩니다.
unsafe블록에 안전하지 않은 연산이 전혀 없는 경우unsafe블록이 다른 unsafe 블록 안에 있고, 바깥 블록이 사용되지 않은 것으로 간주되지 않는 경우
#![allow(unused)]
#![deny(unused_unsafe)]
fn main() {
let y = 0;
let x: *const u8 = core::ptr::addr_of!(y);
unsafe { // lint reported for this block
unsafe {
let z = *x;
}
let safe_expr = 123;
}
unsafe {
unsafe { // lint reported for this block
let z = *x;
}
let unsafe_expr = *x;
}
}
unsafe와 관련된 기타 검사
안전하지 않은 트레이트를 구현하려면 unsafe impl이 필요하며, 이에 대한 검사는 일관성(coherence) 검사의 일부로 수행됩니다. unsafe_code 린트는 AST에 대한 린트 패스로 실행되며, unsafe 블록, 함수, 구현뿐만 아니라 특정 unsafe 속성도 찾아냅니다.
데이터 흐름 분석
MIR 관련 작업을 하다 보면 다양한 종류의 데이터 흐름 분석을 자주 접하게 됩니다. rustc는 데이터 흐름을 사용하여 초기화되지 않은 변수를 찾고, 제너레이터 yield 문을 가로질러 살아 있는 변수가 무엇인지 판별하며, 제어 흐름 그래프의 특정 지점에서 어떤 Place가 빌려졌는지 계산합니다. 데이터 흐름 분석은 현대 컴파일러의 기본 개념이며, 이 주제에 대한 지식은 기여를 희망하는 분들에게 도움이 될 것입니다.
그러나 이 문서는 데이터 흐름 분석에 대한 일반적인 입문서가 아닙니다. 이 문서는 rustc에서 이러한 분석을 정의하는 데 사용되는 프레임워크를 설명할 뿐입니다. 이 문서는 독자가 핵심 개념뿐만 아니라 “전달 함수(transfer function)”, “고정점(fixpoint)”, “격자(lattice)“와 같은 기본 용어에도 익숙하다고 가정합니다. 이러한 용어가 익숙하지 않거나 빠르게 복습하고 싶다면 Static Program Analysis를 참고하십시오. 무료로 제공되는 훌륭한 교재입니다. 시청각 학습을 선호하는 분들을 위해 이전에는 YouTube에 올라온 프랑크푸르트 괴테 대학교의 짧은 강의 시리즈를 추천했으나, 현재는 삭제되었습니다. 관련 경위는 이 PR을, 대체 강의는 이 댓글을 참고하십시오.
데이터 흐름 분석 정의하기
데이터 흐름 분석은 Analysis 트레이트로 정의됩니다. 이 트레이트는 데이터 흐름 상태의 타입 외에도 각 블록 진입 시점의 상태 초기값과 분석 방향(순방향 또는 역방향)을 정의합니다. 데이터 흐름 분석의 도메인은 잘 동작하는 join 연산자를 갖춘 격자(엄밀히 말하면 결합 반격자(join-semilattice))여야 합니다. 자세한 내용은 lattice 모듈과 JoinSemiLattice 트레이트의 문서를 참고하십시오.
전달 함수와 효과
rustc의 데이터 흐름 프레임워크에서는 기본 블록 내부의 각 문(및 종결자)이 자체 전달 함수를 정의할 수 있습니다. 간결하게 부르기 위해 이러한 개별 전달 함수를 “효과(effect)“라고 합니다. 각 효과는 데이터 흐름 순서에 따라 차례로 적용되며, 이들이 모여 기본 블록 전체의 전달 함수를 정의합니다. 일부 종결자의 특정 출력 간선에 대해 효과를 정의하는 것도 가능합니다(예: Call 종결자의 success 간선에 대한 apply_call_return_effect). 이들을 통틀어 “간선별 효과(per-edge effect)“라고 부릅니다.
“이전(Before)” 효과
문서를 주의 깊게 읽은 독자라면 각 문과 종결자에 실제로 두 가지 효과, 즉 “이전(before)” 효과와 접두사가 없는(또는 “주(primary)”) 효과가 있을 수 있다는 것을 알아차렸을 것입니다. “이전” 효과는 분석 방향과 관계없이 접두사가 없는 효과 바로 직전에 적용됩니다. 다시 말해, 역방향 분석도 순방향 분석과 마찬가지로 기본 블록의 전달 함수를 계산할 때 “이전” 효과를 적용한 다음 “주” 효과를 적용합니다.
대부분의 분석은 접두사가 없는 효과만 사용해야 합니다. 각 문에 여러 효과가 있으면 사용하는 쪽에서 어디를 살펴봐야 할지 알기 어렵기 때문입니다. 그러나 대입문의 우변 효과를 좌변과 별도로 고려해야 하는 경우처럼, 일부 상황에서는 “이전” 변형이 유용할 수 있습니다.
수렴
분석은 반드시 “고정점“으로 수렴해야 하며, 그렇지 않으면 영원히 실행됩니다. 고정점으로 수렴한다는 것은 “평형에 도달한다“는 것을 달리 표현한 것일 뿐입니다. 평형에 도달하려면 분석이 몇 가지 법칙을 따라야 합니다. 따라야 하는 법칙 중 하나는 바닥 값1을 다른 어떤 값과 결합(join)하면 그 두 번째 값과 같아야 한다는 것입니다. 이를 수식으로 나타내면 다음과 같습니다.
bottom join x = x
또 다른 법칙은 분석에 다음을 만족하는 “top 값“이 있어야 한다는 것입니다.
top join x = top
top 값이 있으면 반격자(semilattice)의 높이가 유한함이 보장되며, 위의 법칙은 데이터 흐름 상태가 일단 top에 도달하면 더 이상 변하지 않음을 보장합니다(고정점은 top이 됩니다).
간단한 예제
이 절에서는 간단한 데이터 흐름 분석의 예제를 개략적으로 살펴봅니다. 알아야 할 내용을 모두 설명하지는 않지만, 이 페이지의 나머지 부분을 이해하는 데 도움이 되기를 바랍니다.
프로그램의 특정 지점에 이르기까지 mem::transmute가 호출되었을 수 있는지 알아내는 간단한 분석을 만든다고 가정해 봅시다. 분석 도메인은 지금까지 transmute가 호출되었는지를 기록하는 bool 하나뿐입니다. 기본적으로 transmute는 호출되지 않은 상태이므로 bottom 값은 false입니다. transmute가 호출되었음을 확인하는 즉시 분석이 끝나므로 top 값은 true입니다. join 연산자는 단순히 불리언 OR(||) 연산자입니다. AND가 아니라 OR를 사용하는 이유는 다음과 같은 경우 때문입니다.
#![allow(unused)]
fn main() {
unsafe fn example(some_cond: bool) {
let x = if some_cond {
std::mem::transmute::<i32, u32>(0_i32) // transmute was called!
} else {
1_u32 // transmute was not called
};
// Has transmute been called by this point? We conservatively approximate that
// as yes, and that is why we use the OR operator.
println!("x: {}", x);
}
}
데이터 흐름 분석 결과 살펴보기
분석을 구성한 뒤에는 iterate_to_fixpoint를 호출해야 합니다. 이 함수는 각 블록에 진입할 때의 고정점 데이터 흐름 상태를 담은 Results를 반환합니다. Results가 있으면 CFG의 어느 지점에서든 고정점 데이터 흐름 상태를 살펴볼 수 있습니다. 몇몇 위치(예: 각 Drop 종결자)의 상태만 필요하다면 ResultsCursor를 사용하십시오. 모든 위치의 상태가 필요하다면 ResultsVisitor가 더 효율적입니다.
Analysis
|
| iterate_to_fixpoint()
|
Results
/ \
into_results_cursor(…) / \ visit_with(…)
/ \
ResultsCursor ResultsVisitor
예를 들어 다음 코드는 ResultsVisitor를 사용합니다…
// Assuming `MyVisitor` implements `ResultsVisitor<FlowState = MyAnalysis::Domain>`...
let mut my_visitor = MyVisitor::new();
// inspect the fixpoint state for every location within every block in RPO.
let results = MyAnalysis::new()
.iterate_to_fixpoint(tcx, body, None);
results.visit_with(body, &mut my_visitor);`
반면 이 코드는 ResultsCursor를 사용합니다.
let mut results = MyAnalysis::new()
.iterate_to_fixpoint(tcx, body, None);
.into_results_cursor(body);
// Inspect the fixpoint state immediately before each `Drop` terminator.
for (bb, block) in body.basic_blocks().iter_enumerated() {
if let TerminatorKind::Drop { .. } = block.terminator().kind {
results.seek_before_primary_effect(body.terminator_loc(bb));
let state = results.get();
println!("state before drop: {:#?}", state);
}
}
Graphviz 다이어그램
데이터 흐름 분석 결과가 예상과 다를 때는 결과를 시각화해 보면 도움이 되는 경우가 많습니다. 시각화는 MIR 디버깅에서 설명하는 -Z dump-mir 플래그로 할 수 있습니다. -Z dump-mir=F -Z dump-mir-dataflow로 시작하십시오. 여기서 F는 “all“이거나 살펴보려는 MIR 본문의 이름입니다.
이 .dot 파일은 mir_dump 디렉터리에 저장되며, 파일 이름에 분석의 NAME(예: maybe_inits)이 들어갑니다. 각 시각화에는 각 블록의 진입 시점과 종료 시점의 전체 데이터 흐름 상태와 함께, 각 문장과 종결자에서 일어나는 모든 변화가 표시됩니다. 아래 예제를 참고하십시오.
-
bottom 값의 주된 용도는 초기 데이터 흐름 상태입니다. 분석이 시작되기 전에 각 기본 블록의 진입 상태는 bottom으로 초기화됩니다. ↩
드롭 정교화
동적 드롭
Rust 레퍼런스에 따르면 다음과 같습니다.
초기화된 변수나 임시 값이 스코프를 벗어나면 그 소멸자가 실행됩니다. 즉, 드롭됩니다. 대입 역시 왼쪽 피연산자가 초기화되어 있다면 그 소멸자를 실행합니다. 변수가 부분적으로 초기화된 경우에는 초기화된 필드만 드롭됩니다.
MIR을 구성할 때 Drop과 DropAndReplace 종결자는 드롭이 일어날 수 있는 위치를 나타냅니다. 그러나 이 단계에서는 이러한 종결자가 있다고 해서 소멸자가 반드시 실행된다는 보장은 없습니다. 종결자에 도달하기 전에 드롭 대상이 초기화되지 않은 상태일 수 있기 때문입니다(보통은 그 대상에서 값이 이동되었기 때문입니다). 일반적으로 변수가 초기화되었는지 여부는 컴파일 시점에 알 수 없습니다.
#![allow(unused)]
fn main() {
let mut y = vec![];
{
let x = vec![1, 2, 3];
if std::process::id() % 2 == 0 {
y = x; // conditionally move `x` into `y`
}
} // `x` goes out of scope here. Should it be dropped?
}
이러한 경우에는 변수가 초기화되었는지 여부를 동적으로 추적해야 합니다. 관련 규칙은 RFC 320: Non-zeroing dynamic drops에 자세히 설명되어 있습니다.
드롭 의무
RFC에서 인용하면 다음과 같습니다.
지역 변수가 초기화되면 “드롭 의무” 집합이 생깁니다. 드롭 의무란 드롭되어야 하는 구조적 경로(예: 지역 변수
a또는 필드로 가는 경로b.f.y)의 집합입니다.구조체 타입
T인 지역 변수 x의 드롭 의무는T의 구조를 분석하여 계산합니다.T자체가Drop을 구현한다면x가 드롭 의무가 됩니다.T가Drop을 구현하지 않는다면 드롭 의무 집합은T의 각 필드가 가진 드롭 의무의 합집합입니다.
구조적 경로에서 값이 이동되면(그래서 초기화되지 않은 상태가 되면) 그 경로나 그 하위 경로(path.f, path.f.g.h 등)에 대한 모든 드롭 의무가 해제됩니다. Drop 구현이 있는 타입은 개별 필드에서 값을 이동하는 것을 허용하지 않으므로, 그러한 타입을 거쳐 초기화 여부를 추적할 필요가 없습니다.
지역 변수가 스코프를 벗어날 때(Drop), 또는 구조적 경로가 대입으로 덮어쓰일 때(DropAndReplace) 해당 변수나 경로에 대한 드롭 의무가 있는지 확인합니다. 그 시점까지 해당 의무가 해제되지 않았다면 연관된 Drop 구현이 호출됩니다. enum 타입의 경우에는 “활성” 배리언트에 해당하는 필드만 드롭하면 됩니다. 이러한 타입의 드롭 의무를 처리할 때는 먼저 판별값을 검사하여 활성 배리언트를 결정합니다. 활성 배리언트를 제외한 나머지 배리언트의 드롭 의무는 모두 무시됩니다.
다음은 이 규칙을 설명하는 데 도움이 되는 몇 가지 흥미로운 타입입니다.
#![allow(unused)]
fn main() {
struct NoDrop(u8); // No `Drop` impl. No fields with `Drop` impls.
struct NeedsDrop(Vec<u8>); // No `Drop` impl but has fields with `Drop` impls.
struct ThinVec(*const u8); // Custom `Drop` impl. Individual fields cannot be moved from.
impl Drop for ThinVec {
fn drop(&mut self) { /* ... */ }
}
enum MaybeDrop {
Yes(NeedsDrop),
No(NoDrop),
}
}
드롭 정교화
이 규칙을 구현하는 타당한 모델 하나는 함수 안에서 한 번이라도 사용되는 모든 구조적 경로마다 불리언 플래그(“드롭 플래그”)를 하나씩 두는 것입니다. 이 플래그는 해당 경로가 초기화될 때 설정되고, 그 경로에서 값이 이동될 때 해제됩니다. Drop이 발생하면 Drop의 대상과 연관된 모든 의무의 플래그를 검사하고, 여전히 적용 가능한 의무에 대해 연관된 Drop impl을 호출합니다.
새로 빌드된 MIR의 부정확한 Drop 및 DropAndReplace 종결자를 드롭 플래그가 있는 형태로 변환하는 이 과정을 드롭 정교화(drop elaboration)라고 합니다. MIR 문장으로 인해 변수가 초기화(또는 초기화 해제)되면, 드롭 정교화는 해당 변수의 드롭 플래그를 설정(또는 해제)하는 코드를 삽입합니다. 또한 Drop 종결자를 새로 삽입된 드롭 플래그를 검사하는 조건문으로 감쌉니다.
드롭 정교화는 DropAndReplace 종결자를 대상에 대한 Drop과 방금 드롭된 위치에 대한 쓰기로 분리하기도 합니다. 이는 위에서 논의한 내용과는 다소 관련이 없습니다.
이 작업이 완료되면 MIR의 Drop 종결자는 드롭된 위치의 타입에 대한 “드롭 글루(drop glue)” 또는 “드롭 심(drop shim)” 호출에 대응합니다. 어떤 타입의 드롭 글루는 해당 타입의 Drop impl을 (존재한다면) 호출한 다음, 해당 타입의 모든 필드에 대해 드롭 글루를 재귀적으로 호출합니다.
rustc의 드롭 정교화
위에서 설명한 방식은 필요 이상으로 비용이 많이 듭니다. 몇 가지 최적화를 생각해 볼 수 있습니다.
Drop의 대상인 경로(또는 그 대상을 접두사로 갖는 경로)만 드롭 플래그가 필요합니다.- 일부 변수는 드롭될 때 초기화되어 있음(또는 초기화되어 있지 않음)이 알려져 있습니다. 이러한 변수에는 드롭 플래그가 필요하지 않습니다.
- 경로 집합이 공유된 접두사를 통해서만 드롭되거나 이동된다면, 해당 경로들은 하나의 드롭 플래그를 공유할 수 있습니다.
이 중 일부가 rustc에 구현되어 있습니다.
컴파일러에서 드롭 정교화는 여러 모듈에 나뉘어 있습니다. 패스 자체는 여기에 정의되어 있지만, 주요 로직은 드롭 심을 빌드하는 데에도 사용되므로 다른 곳에 정의되어 있습니다.
드롭 정교화는 새로 빌드된 MIR의 각 Drop을 다음 네 가지 종류 중 하나로 지정합니다.
Static: 대상이 항상 초기화되어 있습니다.Dead: 대상이 항상 초기화되어 있지 않습니다.Conditional: 대상이 전체적으로 초기화되어 있거나 전체적으로 초기화되어 있지 않습니다. 부분적으로 초기화되어 있지는 않습니다.Open: 대상이 부분적으로 초기화되어 있을 수 있습니다.
이를 위해 MaybeInitializedPlaces와 MaybeUninitializedPlaces라는 한 쌍의 데이터 흐름 분석을 사용합니다. 어떤 위치가 한쪽에는 있고 다른 쪽에는 없다면, 대상의 초기화 여부는 컴파일 시점에 알려져 있습니다(Dead 또는 Static). 이 경우 드롭 정교화는 대상에 대한 플래그를 추가하지 않습니다. 단순히 Drop 종결자를 제거하거나(Dead) 유지합니다(Static).
Conditional 드롭의 경우, 변수 전체의 초기화 여부가 그 필드들의 초기화 여부와 같다는 것을 알고 있습니다. 따라서 해당 드롭의 대상에 대한 드롭 플래그를 생성하고 나면, 그 대상에 대한 드롭 글루를 호출해도 안전합니다.
Open 드롭
Open 드롭은 가장 복잡합니다. 하나의 Drop 종결자를 여러 개의 서로 다른 종결자로 나누어야 하기 때문입니다. 이때 대상의 필드 가운데 타입에 드롭 글루가 있는(Ty::needs_drop) 필드마다 종결자가 하나씩 만들어집니다. 대상 자체에 대한 드롭 글루는 호출할 수 없습니다. 그렇게 하려면 대상의 모든 필드가 초기화되어 있어야 하기 때문입니다. 사용자 정의 Drop impl이 있는 타입의 변수는 필드를 이동해 낼 수 없으므로 Open 드롭을 허용하지 않는다는 점을 기억하십시오.
이는 각 필드를 Dead, Static, Conditional, Open 중 하나로 재귀적으로 분류하는 방식으로 이루어집니다. 타입에 드롭 글루가 없는 필드는 자동으로 Dead이므로 재귀 과정에서 고려할 필요가 없습니다. 종류가 Open이 아닌 필드에 도달하면 앞에서 설명한 방식대로 처리합니다. 필드도 Open이면 재귀가 계속됩니다.
열거형의 Open 드롭을 처리하는 방식은 짚어 볼 만합니다. 드롭 정교화 과정에서 열거형의 각 변형은 하나의 필드처럼 취급되며, 이러한 “변형 필드” 중 어느 시점에든 하나만 초기화될 수 있다는 불변 조건이 적용됩니다. 일반적인 경우에는 어떤 배리언트가 활성 상태인지 알 수 없으므로, (판별값을 검사하는) 열거형의 드롭 글루를 호출하거나, 정교화된 Open 드롭의 일부로서 판별값을 직접 검사해야 합니다. 하지만 특정한 경우(예를 들어 match 갈래 안)에는 열거형의 어느 변형이 활성 상태인지 알 수 있습니다. 이 정보는 비활성 변형에 해당하는 모든 위치를 초기화되지 않은 것으로 표시하는 방식으로 MaybeInitializedPlaces 및 MaybeUninitializedPlaces 데이터 흐름 분석에 인코딩됩니다.
정리 경로
TODO: 드롭 정교화와 언와인딩에 대해 설명합니다.
여담: 드롭 정교화와 상수 평가
Rust에서 컴파일 타임에 평가될 수 있는 함수는 const 키워드로 명시적으로 표시해야 합니다. 여기에는 Drop 트레이트의 구현도 포함되며, 이러한 구현은 const일 수도 있고 아닐 수도 있습니다. 컴파일 타임 평가 대상이 될 수 있는 코드는 const 함수만 호출할 수 있으므로, 그러한 코드에서 const가 아닌 Drop 구현을 호출하는 것은 모두 금지되어야 합니다.
Drop impl 호출은 MIR에서 Drop 종결자로 인코딩됩니다. 하지만 앞에서 설명했듯이, 새로 빌드된 MIR의 Drop 종결자가 반드시 Drop::drop 호출로 이어지는 것은 아닙니다. 그 시점에 드롭 대상이 초기화되어 있지 않을 수 있습니다. 따라서 새로 빌드된 MIR에서 const가 아닌 Drop을 검사하면 잘못된 오류가 발생할 수 있습니다. 그 대신 드롭 정교화가 실행된 뒤에 이러한 검사를 수행합니다. 드롭 정교화는 Dead 드롭(대상이 초기화되지 않은 것으로 알려진 드롭)을 제거합니다.
MIR 빌림 검사
빌림 검사는 Rust의 “비법 소스“로, 다음과 같은 여러 속성을 강제하는 역할을 맡습니다.
- 모든 변수는 사용되기 전에 초기화되어야 합니다.
- 같은 값을 두 번 이동할 수 없습니다.
- 빌려진 상태의 값은 이동할 수 없습니다.
- 가변으로 빌려진 상태의 장소에는 (해당 참조를 통하는 경우를 제외하고) 접근할 수 없습니다.
- 불변으로 빌려진 상태의 장소는 변경할 수 없습니다.
- 등
빌림 검사기는 MIR 위에서 동작합니다. 이전 구현은 HIR 위에서 동작했습니다. MIR에서 빌림 검사를 수행하면 여러 가지 이점이 있습니다.
- MIR은 HIR보다 훨씬 덜 복잡하며, 이러한 과감한 디슈가링은 빌림 검사기의 버그를 예방하는 데 도움이 됩니다. (궁금하다면 MIR 기반 빌림 검사기가 수정한 버그 목록을 여기에서 볼 수 있습니다.)
- 더욱 중요한 점은, MIR을 사용하면 제어 흐름 그래프로부터 도출되는 영역인 “비어휘적 라이프타임(non-lexical lifetimes)”이 가능해진다는 것입니다.
빌림 검사기의 주요 단계
빌림 검사기의 소스는 rustc_borrowck 크레이트에 있습니다. 주요 진입점은 mir_borrowck 쿼리입니다.
- 먼저 MIR의 로컬 복사본을 만듭니다. 이후 단계에서는 이 복사본을 제자리에서 수정하여, 타입 등이 우리가 계산하고 있는 새로운 영역에 대한 참조를 포함하도록 변경합니다.
- 그다음
replace_regions_in_mir를 호출하여 로컬 MIR을 수정합니다. 이 함수는 여러 작업 중에서도 특히 MIR 안의 모든 영역을 새로운 추론 변수로 대체합니다. - 다음으로, 어떤 데이터가 언제 이동되는지를 계산하는 여러 데이터 흐름 분석을 수행합니다.
- 그다음 MIR 전체에 걸쳐 두 번째 타입 검사를 수행합니다. 이 타입 검사의 목적은 서로 다른 영역 사이의 모든 제약 조건을 결정하는 것입니다.
- 다음으로 영역 추론을 수행하는데, 이는 각 영역의 값, 즉 기본적으로 우리가 수집한 제약 조건에 따라 각 라이프타임이 유효해야 하는 제어 흐름 그래프상의 지점들을 계산합니다.
- 이 시점에서 각 지점의 “스코프 내 빌림“을 계산할 수 있습니다.
- 마지막으로 MIR을 한 번 더 순회하면서 MIR이 수행하는 동작을 살펴보고 오류를 보고합니다. 예를 들어
*a + 1과 같은 문장을 발견하면, 변수a가 초기화되어 있는지, 그리고 가변으로 빌려진 상태가 아닌지를 검사합니다. 둘 중 어느 경우든 오류를 보고해야 하기 때문입니다. 이 검사를 수행하려면 앞선 모든 분석의 결과가 필요합니다.
빌림 검사기 디버깅
빌림 검사기 디버깅과 MIR 디버깅을 참고하십시오.
이동과 초기화 추적
빌림 검사기가 하는 일 중 하나는 임의의 시점에 어떤 변수가 “초기화“되어 있는지 추적하는 것입니다. 이를 위해서는 이동이 어디에서 일어나는지 파악하고 추적하는 일도 필요합니다.
초기화와 이동
사용자의 관점에서 보면 초기화(변수에 어떤 값을 부여하는 것)와 이동(소유권을 다른 곳으로 넘기는 것)은 별개의 주제처럼 보일 수 있습니다. 실제로 빌림 검사기의 오류 메시지도 이 둘을 서로 다르게 설명하는 경우가 많습니다. 하지만 빌림 검사기 내부에서는 이 둘이 그렇게까지 분리되어 있지 않습니다. 대략적으로 말하면, 빌림 검사기는 소스 코드의 모든 지점에서 “초기화된 위치“의 집합을 추적합니다. 이전에 초기화되지 않았던 지역 변수에 값을 대입하면 그 변수가 이 집합에 추가되고, 지역 변수에서 값을 이동하면 그 변수가 이 집합에서 제거됩니다.
다음 예제를 살펴보십시오.
fn foo() {
let a: Vec<u32>;
// a is not initialized yet
a = vec![22];
// a is initialized here
std::mem::drop(a); // a is moved here
// a is no longer initialized here
let l = a.len(); //~ ERROR
}
여기서 a는 초기화되지 않은 상태로 시작하며, 값이 대입되면 초기화된 상태가 된다는 것을 알 수 있습니다. 하지만 drop(a)가 호출되면 a가 그 호출 안으로 이동하므로, 다시 초기화되지 않은 상태가 됩니다.
하위 절
살펴보기 쉽도록 이 절은 여러 하위 절로 나뉘어 있습니다.
- 이동 경로: 어떤 지역 변수(경우에 따라서는 지역 변수의 일부)가 초기화되어 있는지 추적하는 데 사용하는 이동 경로 개념입니다.
- TODO 나머지는 아직 작성되지 않았습니다 =)
이동 경로
실제로는 지역 변수 단위로 초기화를 추적하는 것만으로는 충분하지 않습니다. Rust에서는 필드 단위로도 이동과 초기화를 수행할 수 있습니다:
fn foo() {
let a: (Vec<u32>, Vec<u32>) = (vec![22], vec![44]);
// a.0 and a.1 are both initialized
let b = a.0; // moves a.0
// a.0 is not initialized, but a.1 still is
let c = a.0; // ERROR
let d = a.1; // OK
}
이를 처리하기 위해 우리는 이동 경로(move path) 단위로 초기화를 추적합니다. MovePath는 사용자가 초기화하거나 이동하는 등의 작업을 할 수 있는 어떤 위치를 나타냅니다. 예를 들어 지역 변수 a를 나타내는 이동 경로가 있고, a.0을 나타내는 이동 경로도 있습니다. 이동 경로는 MIR의 Place 개념에 대략 대응하지만, 이동 분석을 더 효율적으로 수행할 수 있는 방식으로 인덱싱됩니다.
이동 경로 인덱스
MovePath 자료 구조가 존재하기는 하지만, 이를 직접 참조하는 일은 없습니다. 대신 모든 코드는 MovePathIndex 타입의 인덱스를 주고받습니다. 이동 경로에 대한 정보가 필요하다면 이 인덱스를 MoveData의 move_paths 필드와 함께 사용합니다. 예를 들어 MovePathIndex mpi를 MIR Place로 변환하려면 다음과 같이 MovePath::place 필드에 접근할 수 있습니다:
move_data.move_paths[mpi].place
이동 경로 구성하기
MIR 빌림 검사에서 가장 먼저 하는 일 중 하나는 이동 경로의 집합을 구성하는 것입니다. 이 작업은 MoveData::gather_moves 함수의 일부로 수행됩니다. 이 함수는 MoveDataBuilder라는 MIR 방문자(visitor)를 사용하여 MIR을 순회하면서 그 안의 각 Place가 어떻게 접근되는지 살펴봅니다. 그러한 각 Place에 대해 대응하는 MovePathIndex를 구성합니다. 또한 해당 이동 경로가 언제/어디서 이동/초기화되는지도 기록하지만, 이에 대해서는 이후 절에서 다루겠습니다.
허용되지 않는 이동 경로
실제로는 사용되는 모든 Place에 대해 이동 경로를 만들지는 않습니다. 특히 어떤 Place에서 이동하는 것이 허용되지 않는다면 MovePathIndex가 필요하지 않습니다. 몇 가지 예는 다음과 같습니다:
- 배열의 개별 요소는 이동할 수 없으므로, 예를 들어
foo: [String; 3]이 있다면foo[1]에 대한 이동 경로는 존재하지 않습니다. - 빌린 참조의 내부에서는 이동할 수 없으므로, 예를 들어
foo: &String이 있다면*foo에 대한 이동 경로는 존재하지 않습니다.
이러한 규칙은 Place를 MovePathIndex로 변환하는 move_path_for 함수가 강제합니다. 방금 살펴본 것과 같은 오류 상황에서 이 함수는 Err를 반환합니다. 이는 결과적으로 해당 위치들이 초기화되었는지 굳이 추적할 필요가 없다는 뜻입니다(이로써 오버헤드가 줄어듭니다).
프로젝션
이동 경로의 프로젝션은 PlaceElem을 사용하는 대신 MoveSubPath로 저장됩니다. 밖으로 이동할 수 없는 프로젝션과 건너뛸 수 있는 프로젝션은 표현되지 않습니다.
(슬라이스 패턴으로 생성되는) 배열의 부분 슬라이스 프로젝션은 특별합니다. 이는 부분 슬라이스의 각 요소마다 하나씩, 여러 개의 ConstantIndex 하위 경로로 변환됩니다.
이동 경로 조회하기
Place가 있고 이를 MovePathIndex로 변환하고 싶다면, MoveData의 rev_lookup 필드에 있는 MovePathLookup 구조체를 사용하여 변환할 수 있습니다. 두 가지 메서드가 있습니다:
find_local: 지역 변수를 나타내는mir::Local을 인자로 받습니다. 모든 지역 변수에 대해MovePathIndex를 항상 생성하므로 이쪽이 더 쉬운 메서드입니다.find: 임의의Place를 인자로 받습니다. 이 메서드는 사용하기가 조금 더 번거로운데, 바로 앞의 “불법 이동 경로” 절에서 설명했듯이 모든Place에 대해MovePathIndex가 있는 것은 아니기 때문입니다. 따라서find는 찾을 수 있었던 경로 중 실제로 존재하는 가장 가까운 경로를 나타내는LookupResult를 반환합니다(예를 들어foo[1]에 대해서는foo의 경로만 반환할 수도 있습니다).
상호 참조
앞서 언급했듯이 이동 경로는 큰 벡터에 저장되며 각자의 MovePathIndex를 통해 참조됩니다. 그러나 이 벡터 안에서 이동 경로는 트리 구조로도 구성되어 있습니다. 예를 들어 a.b.c에 대한 MovePathIndex가 있으면 그 부모 이동 경로인 a.b로 이동할 수 있습니다. 모든 자식 경로를 순회할 수도 있습니다. 예를 들어 a.b에서 순회하여 a.b.c 경로를 찾을 수 있습니다(이때 순회하는 대상은 참조될 수 있었던 가능한 모든 경로가 아니라 소스에서 실제로 참조된 경로뿐입니다). 이러한 참조는 예를 들어 find_in_move_path_or_its_descendants 함수에서 사용됩니다. 이 함수는 어떤 이동 경로(예: a.b) 또는 그 이동 경로의 자식(예: a.b.c)이 주어진 술어를 만족하는지 판단합니다.
MIR 타입 검사
Rust의 “정규(canonical)” 타입 검사(라이프타임은 무시함)는 HIR에서 수행됩니다. 그럼에도 우리는 MIR에서도 타입 검사 패스를 수행합니다.
MIR은 완전히 타입이 지정된 중간 표현입니다. 이 시점에는 모든 아이템의 타입과 그 본문의 내용이 알려져 있으며, MIR을 구성했다는 것만으로 그 타입들이 올바르다는 것을 알 수 있습니다. 이미 타입이 지정되고 검사까지 끝난 이 IR에 대해 타입 검사 패스를 다시 수행하는 이유는 빌림 검사에 필요한 라이프타임1에 관한 정보를 수집하기 위함입니다. 참고: borrowck_collect_region_constraints.
이 추가 타입 검사 패스를 수행하면 우리의 작업을 검증할 수도 있습니다. HIR 타입 검사에서는 통과한 것이 MIR 타입 검사에서 실패한다면 무언가 잘못된 것입니다.
“HIR 타입 검사가 성공하면 MIR 타입 검사도 성공해야 한다“는 조건을 유지하는 것은 결코 간단하지 않습니다. 그 주된 이유 중 하나는 MIR 타입 검사 과정에서 기존 라이프타임을 지우고 제약이 없는 새 라이프타임 변수로 대체하는 반면, HIR에서는 라이프타임이 추론되기만 하고 검사되지는 않는다는 점입니다. 이처럼 HIR 타입 검사와 MIR 타입 검사는 각각 미묘하게 다른 정보를 가지고 작업합니다.
MIR에서 라이프타임을 지우고 다시 추론하는 이 전략을 리전 고유화(Region Uniquification)라고 합니다.
리전 고유화
TODO: 리전 고유화와 현재 남아 있는 그 사용처에 대해 더 설명하십시오.
사용자 타입
MIR 타입 검사를 시작할 때 우리는 본문의 모든 리전을 제약이 없는 새 리전으로 대체합니다. 하지만 이렇게 하면 다음 프로그램을 허용하게 됩니다.
#![allow(unused)]
fn main() {
fn foo<'a>(x: &'a u32) {
let y: &'static u32 = x;
}
}
y의 타입에 있는 라이프타임을 지우면 그것이 'static이어야 한다는 사실을 더 이상 알 수 없게 되어 사용자의 의도를 무시하게 됩니다.
이 문제를 해결하기 위해 우리는 HIR 타입 검사 중에 사용자가 타입을 명시적으로 언급한 모든 위치를 CanonicalUserTypeAnnotations로 기억해 둡니다.
우리가 신경 쓰는 어노테이션은 두 가지입니다.
- 명시적 타입 지정(type ascription): 예를 들어
let y: &'static u32는UserType::Ty(&'static u32)가 됩니다. - 명시적 제네릭 인자: 예를 들어
x.foo<&'a u32, Vec<String>>는UserType::TypeOf(foo_def_id, [&'a u32, Vec<String>])가 됩니다.
HIR 타입 검사에서 이루어진 영역 추론이 MIR typeck에 영향을 주지 않도록, 사용자 타입은 HIR에서 로워링한 직후에 저장합니다. 따라서 사용자 타입에 추론 변수가 아직 남아 있을 수 있으며, 이것이 우리가 정규(canonical) 사용자 타입 어노테이션을 사용하는 이유입니다. 대신 우리는 모든 추론 변수를 존재 양화된 바운드 변수로 대체합니다. 따라서 let x: Vec<_>와 같은 것은 exists<T> UserType::Ty(Vec<T>)가 됩니다.
let Foo(x): Foo<&'a u32>와 같은 패턴의 사용자 타입은 Foo<&'a u32>이지만, x의 실제 타입은 &'a u32여야 합니다. 이를 위해 우리는 UserTypeProjection을 사용합니다.
MIR에서는 사용자 타입을 약간 다른 두 가지 방식으로 다룹니다.
명시적 타입 어노테이션이 있는 패턴 안의 변수에 대응하는 MIR 지역 변수(local)가 있으면, 우리는 그 지역 변수의 타입이 UserTypeProjection의 타입과 같아야 한다고 요구합니다. 이 정보는 LocalDecl에 직접 저장됩니다.
또한 스크루티니 표현식(scrutinee expression)의 타입도 제약합니다. 예를 들어 let _: &'a u32 = x;에서 x의 타입이 이에 해당합니다. 여기서 T_x는 사용자 타입의 서브타입이기만 하면 되므로, 이 경우에는 대신 StatementKind::AscribeUserType을 사용합니다.
MIR 타입 검사기는 타입 추론 변수와 상수 추론 변수를 제대로 다루지 않으므로 사용자 타입을 직접 사용하지 않는다는 점에 유의하십시오. 대신 HIR 타입 검사기에서 얻은 최종 inferred_type을 저장합니다. 그런 다음 MIR 타입 검사(typeck) 중에 그 영역(region)들을 새로운 NLL 추론 변수로 교체하고, 이를 실제 UserType과 연관시켜 올바른 영역 제약 조건을 다시 얻습니다.
MIR 타입 검사가 끝나면 모든 사용자 타입 표기는 더 이상 필요하지 않으므로 폐기됩니다.
-
리전(region) 또는 대여(loan)라고도 합니다. ↩
드롭 검사
일반적으로 지역 변수가 사용될 때마다 해당 지역 변수의 타입이 적격이어야 합니다. 여기에는 지역 변수의 where 바운드를 증명하는 것이 포함되며, 지역 변수가 사용하는 모든 영역(region)이 살아 있어야 한다는 조건도 포함됩니다.
이에 대한 유일한 예외는 값이 스코프를 벗어날 때 암묵적으로 드롭되는 경우입니다. 이 경우 값이 반드시 살아 있어야 하는 것은 아닙니다:
fn main() {
let x = vec![];
{
let y = String::from("I am temporary");
x.push(&y);
}
// `x` goes out of scope here, after the reference to `y`
// is invalidated. This means that while dropping `x` its type
// is not well-formed as it contain regions which are not live.
}
이는 값을 드롭할 때 죽은 영역에 접근하지 않는 경우에만 건전합니다. 값의 타입이 drop-live 상태여야 한다고 요구하여 이를 검사합니다. 이에 대한 요구 사항은 fn dropck_outlives에서 계산됩니다.
이 절의 나머지 부분에서는 영역 매개변수가 살아 있어야 하는 타입으로 다음 타입 정의를 사용합니다:
#![allow(unused)]
fn main() {
struct PrintOnDrop<'a>(&'a str);
impl<'a> Drop for PrintOnDrop<'_> {
fn drop(&mut self) {
println!("{}", self.0);
}
}
}
값이 드롭되는 방식
근본적으로 T 타입의 값은 해당 값의 “드롭 글루(drop glue)“를 실행하여 드롭됩니다. 드롭 글루는 컴파일러가 생성하며, 먼저 <T as Drop>::drop을 호출한 다음 재귀적으로 소유된 모든 값의 드롭 글루를 재귀적으로 호출합니다.
T에 명시적인Dropimpl이 있다면<T as Drop>::drop을 호출하십시오.T가Drop을 구현하는지 여부와 관계없이T가 소유한 모든 값으로 재귀합니다:- 참조, 원시 포인터, 함수 포인터, 함수 항목, 트레이트 객체1, 스칼라는 아무것도 소유하지 않습니다.
- 튜플, 슬라이스, 배열은 자신의 요소를 소유한 것으로 간주합니다. 길이가 0인 배열은 요소 타입의 값을 전혀 소유하지 않습니다.
- ADT의 (모든 배리언트의) 모든 필드는 소유된 것으로 간주됩니다. 열거형의 경우 모든 배리언트를 고려합니다. 여기서 예외는
ManuallyDrop<U>로, 이는U를 소유하지 않는 것으로 간주됩니다.PhantomData<U>역시 아무것도 소유하지 않습니다. 클로저와 제너레이터는 캡처한 업바(upvar)를 소유합니다.
타입에 드롭 글루가 있는지 여부는 fn Ty::needs_drop이 반환합니다.
지역 변수의 부분 드롭
Drop을 직접 구현하지 않는 타입의 경우, 나머지를 드롭하기 전에 값의 일부를 먼저 이동할 수도 있습니다. 이 경우 아직 이동되지 않은 값의 드롭 글루만 호출됩니다. 예를 들면 다음과 같습니다.
fn main() {
let mut x = (PrintOnDrop("third"), PrintOnDrop("first"));
drop(x.1);
println!("second")
}
MIR 빌드 중에는 지역 변수의 타입이 드롭을 필요로 하는 한, 지역 변수가 스코프를 벗어날 때마다 드롭될 수 있다고 가정합니다. 변수의 정확한 드롭 글루는 borrowck 이후에 ElaborateDrops 패스에서 계산됩니다. 따라서 지역 변수의 일부가 이전에 드롭되었더라도 dropck는 여전히 이 값이 살아 있을 것을 요구합니다. 이는 지역 변수를 완전히 이동한 경우에도 마찬가지입니다.
fn main() {
let mut x;
{
let temp = String::from("I am temporary");
x = PrintOnDrop(&temp);
drop(x);
}
} //~ ERROR `temp` does not live long enough.
borrowck 이전에 어느 정도의 드롭 정교화(drop elaboration)를 추가하여 이 예제가 컴파일되도록 하는 것이 가능할 것입니다. 드롭 정교화를 const 검사 이전으로 옮기는 불안정 기능이 있습니다: #73255. 관련 MCP가 있기는 하지만, borrowck 이전에 일부 드롭 정교화를 수행하기 위한 기능 게이트는 존재하지 않습니다.
dropck_outlives
우리가 수행하는 “활성(liveness)” 계산은 서로 다른 두 가지입니다.
- 값
v가 이후에 “사용“될 수 있으면 해당 값은 위치L에서 use-live입니다. 여기서 사용이란 기본적으로 드롭이 아닌 모든 것을 말합니다. - 값
v가 이후에 드롭될 수 있으면 해당 값은 위치L에서 drop-live입니다.
어떤 값이 use-live이면 그 타입 전체가 L에서 유효해야 합니다. 어떤 값이 drop-live이면 dropck가 요구하는 모든 리전이 L에서 유효해야 합니다. MIR에서 드롭되는 값은 플레이스(place)입니다.
dropck_outlives가 어떤 타입에 대해 계산하는 제약 조건은 그 타입에 대해 생성되는 드롭 글루(drop glue)와 거의 일치합니다. 드롭 글루와 달리 dropck_outlives는 값 자체가 아니라 소유된 값의 타입을 고려합니다. 타입이 T인 값에 대해서는 다음과 같습니다.
T에 명시적인Drop이 있으면 모든 제네릭 인자가 살아 있을 것을 요구합니다. 단,#[may_dangle]로 표시된 인자는 완전히 무시합니다.T에 명시적인Drop이 있는지와 관계없이T가 소유한 모든 타입으로 재귀합니다.- 참조, 원시 포인터, 함수 포인터, 함수 항목, 트레이트 객체1, 스칼라는 아무것도 소유하지 않습니다.
- 튜플, 슬라이스, 배열은 자신의 원소 타입을 소유한 것으로 간주합니다. 현재 배열에 대해서는 길이가 0인지 검사하지 않습니다.
- ADT의 (모든 variant의) 모든 필드는 소유된 것으로 간주합니다. 여기서 예외는
ManuallyDrop<U>로, 이 타입은U를 소유하지 않는 것으로 간주합니다.PhantomData<U>는U를 소유하는 것으로 간주합니다. - 클로저와 제너레이터는 자신이 캡처한 upvar를 소유합니다.
굵게 표시된 부분은 Ty::needs_drop은 무시하지만 dropck_outlives는 소유된 것으로 간주하는 경우입니다. 우리는 해당 타입을 담고 있는 로컬에 대해 Ty::needs_drop이 true를 반환한 경우에만 dropck_outlives에 의존합니다. 따라서 어떤 타입이 더 큰 로컬 안에 포함되어 있는지에 따라 활성 요구 사항이 달라질 수 있습니다. 이는 일관성이 없으므로 수정되어야 합니다. 배열에 대한 예시와 PhantomData에 대한 예시를 참고하십시오.2
이러한 불일치를 해결할 수 있는 한 가지 방법은 MIR 빌드를 더 비관적으로 만드는 것으로, 아마도 Ty::needs_drop을 더 약하게 만드는 방식이 될 것입니다. 또는 dropck_outlives를 더 정밀하게 바꾸어 살아 있어야 하는 리전의 수를 줄이는 방법도 있습니다.
영역 추론(NLL)
MIR 기반 영역 검사 코드는 rustc_mir::borrow_check 모듈에 있습니다.
MIR 기반 영역 분석은 두 가지 주요 함수로 이루어져 있습니다:
- 먼저 호출되는
replace_regions_in_mir는 두 가지 일을 합니다:- 첫째, 함수 시그니처 안에 나타나는 영역의 집합을 찾습니다(예:
fn foo<'a>(&'a u32) { ... }의'a). 이러한 영역을 “보편(universal)” 영역 또는 “자유(free)” 영역이라고 부르며, 특히 이들은 함수 본문에서 자유롭게 나타나는 영역입니다. - 둘째, 함수 본문의 모든 영역을 새로운 추론 변수로 바꿉니다. 이는 (현재로서는) 그 영역들이 어휘적(lexical) 영역 추론의 결과이므로 별로 중요하지 않기 때문입니다. 궁극적으로는 어휘적 영역 추론을 전혀 수행하지 않을 것이므로, 이 영역들은 “지워진 영역(erased region)”(즉, 아무 정보도 없는 영역)이 되는 것이 목표입니다.
- 첫째, 함수 시그니처 안에 나타나는 영역의 집합을 찾습니다(예:
- 두 번째로 호출되는
compute_regions: 이 함수는 이동 분석(move analysis)의 결과를 인자로 받습니다. 이 함수는replace_regions_in_mir가 도입한 모든 추론 변수의 값을 계산하는 일을 맡습니다.- 이를 위해 먼저 MIR 타입 검사기를 실행합니다. 이는 기본적으로 일반적인 타입 검사기이지만 MIR에 특화되어 있으며, 물론 MIR은 완전한 Rust보다 훨씬 단순합니다. 그러나 MIR 타입 검사기를 실행하면 영역 변수 사이에 다양한 제약 조건이 생성되며, 이 제약 조건은 영역 변수가 가질 수 있는 값과 서로 간의 관계를 나타냅니다.
- 그다음
RegionInferenceContext를 생성하고 그solve메서드를 호출하여 제약 전파를 수행합니다. - NLL RFC에서도 이 내용을 꽤 철저하게 (그리고 바라건대 읽기 쉽게) 다룹니다.
보편 영역
UniversalRegions 타입은 어떤 MIR DefId에 대응하는 보편 영역의 모음을 나타냅니다. 이 타입은 모든 영역을 새로운 추론 변수로 바꿀 때 replace_regions_in_mir에서 생성됩니다. UniversalRegions는 주어진 MIR의 모든 자유 영역에 대한 인덱스와 함께, 그 영역들 사이에 성립한다고 알려진 관계(예: 암시적 바운드, where 절 등)를 담고 있습니다.
예를 들어, 다음 함수의 MIR이 주어졌다고 합시다:
#![allow(unused)]
fn main() {
fn foo<'a>(x: &'a u32) {
// ...
}
}
이 경우 'a에 대한 보편 영역 하나와 'static에 대한 보편 영역 하나를 생성합니다. 클로저를 처리하는 데에는 몇 가지 복잡한 문제가 더 있을 수 있지만, 지금은 무시하겠습니다.
TODO: 이 영역들이 어떻게 계산되는지 작성하십시오.
영역 변수
영역의 값은 하나의 집합으로 생각할 수 있습니다. 이 집합에는 해당 영역이 유효한 MIR의 모든 지점과 함께, 이 영역이 더 오래 사는(outlive) 모든 영역이 들어 있습니다(예: 'a: 'b이면 end('b)는 'a의 집합에 속합니다). 이 집합의 정의역을 RegionElement라고 부릅니다. 코드에서 모든 영역의 값은 rustc_borrowck::region_infer 모듈에서 관리합니다. 각 영역마다 그 값에 어떤 원소가 들어 있는지 저장하는 집합을 관리합니다(이를 효율적으로 하기 위해 원소의 종류마다 인덱스인 RegionElementIndex를 부여하고 희소 비트셋을 사용합니다).
영역 원소의 종류는 다음과 같습니다:
- MIR 제어 흐름 그래프의 각
location: location은 단순히 기본 블록과 인덱스의 쌍입니다. 이는 해당 인덱스의 문장(인덱스가statements.len()과 같다면 종결자)에 진입하는 지점을 나타냅니다. - 각 보편 영역
'a마다end('a)요소가 있으며, 이는 호출자(또는 호출자의 호출자 등)의 제어 흐름 그래프 중 일부에 해당합니다. - 마찬가지로, 이 함수가 반환된 이후의 나머지 프로그램 실행에 해당하는,
end('static)으로 표기되는 요소가 있습니다. - 각 플레이스홀더 영역
!1마다!1요소가 있습니다. 이는 (직관적으로) 알 수 없는 다른 요소들의 집합에 해당합니다. 플레이스홀더에 대한 자세한 내용은 플레이스홀더와 유니버스 절을 참조하십시오.
제약
영역의 값을 추론하려면 먼저 영역에 대한 제약 조건을 수집해야 합니다. 제약의 전체 집합은 제약 전파에 관한 절에서 설명하지만, 가장 흔한 두 가지 종류의 제약은 다음과 같습니다.
- outlives 제약 조건. 이는 한 영역이 다른 영역보다 오래 산다는 제약 조건입니다(예:
'a: 'b). outlives 제약 조건은 MIR 타입 검사기가 생성합니다. - 활성 제약. 각 영역은 사용될 수 있는 지점에서 활성 상태여야 합니다.
추론 개요
그렇다면 영역의 내용은 어떻게 계산합니까? 이 과정을 영역 추론이라고 합니다. 개략적인 아이디어는 꽤 단순하지만, 신경 써야 할 세부 사항이 몇 가지 있습니다.
개략적인 아이디어는 다음과 같습니다. 먼저 각 영역을, 활성 제약 조건에 따라 반드시 그 영역에 포함되어야 한다고 알고 있는 MIR 위치들로 초기화합니다. 거기서부터 타입 검사기가 계산한 모든 outlives 제약 조건을 사용하여 제약 조건을 전파합니다. 즉, 각 영역 'a에 대해 'a: 'b이면 end('b)를 포함한 'b의 모든 요소를 'a에 추가합니다. 이 모든 작업은 propagate_constraints에서 이루어집니다.
그런 다음 오류를 검사합니다. 먼저 check_type_tests를 호출하여 타입 테스트가 충족되는지 검사합니다. 이는 T: 'a와 같은 제약 조건을 검사합니다. 둘째로, 보편 영역이 “너무 크지” 않은지 검사합니다. 이는 check_universal_regions를 호출하여 수행합니다. 이는 각 영역 'a에 대해, 'a가 end('b) 요소를 포함한다면 'a: 'b가 성립한다는 것을 (예: where 절로부터) 이미 알고 있어야 한다는 점을 검사합니다. 이를 이미 알고 있지 않다면 오류입니다… 음, 거의 그렇습니다. 클로저에는 나중에 논의할 특별한 처리가 몇 가지 있습니다.
예시
다음 예제를 살펴보십시오.
fn foo<'a, 'b>(x: &'a usize) -> &'b usize {
x
}
'a가 'b보다 오래 사는지 알 수 없으므로 이 코드는 당연히 컴파일되어서는 안 됩니다(오래 살지 않는다면 반환 값이 댕글링 참조가 될 수 있습니다).
잠시 한 걸음 물러서 봅시다. (replace_regions_in_mir에서 하는 것처럼) 몇 가지 자유 추론 변수를 도입해야 합니다. 이 예제는 실제로 생성되는 영역을 그대로 사용하지는 않지만, 개념을 전달하기에는 (바라건대) 충분합니다.
fn foo<'a, 'b>(x: &'a /* '#1 */ usize) -> &'b /* '#3 */ usize {
x // '#2, location L1
}
표기법: '#1, '#3, '#2는 각각 인자, 반환값, 표현식 x에 대한 보편 영역을 나타냅니다. 또한 표현식 x의 위치를 L1이라고 부르겠습니다.
이제 활성 제약 조건을 사용하여 다음과 같은 시작점을 얻을 수 있습니다.
| 영역 | 내용 |
|---|---|
| ’#1 | |
| ’#2 | L1 |
| ’#3 | L1 |
이제 outlives 제약 조건을 사용하여 각 영역을 확장합니다. 구체적으로, '#2: '#3임을 알고 있으므로 …
| 영역 | 내용 |
|---|---|
| ’#1 | L1 |
| ’#2 | L1, end('#3) // add contents of '#3 and end('#3) |
| ’#3 | L1 |
… 또한 '#1: '#2이므로 …
| 영역 | 내용 |
|---|---|
| ’#1 | L1, end('#2), end('#3) // add contents of '#2 and end('#2) |
| ’#2 | L1, end('#3) |
| ’#3 | L1 |
이제 너무 큰 영역이 없는지 확인해야 합니다(이 경우에는 확인할 타입 테스트가 없습니다). 이제 '#1에 end('#3)가 포함되어 있지만, 'a: 'b임을 나타내는 where 절이나 암시적 바운드가 없다는 점에 주목하십시오. 이것은 오류입니다!
세부 사항
RegionInferenceContext 타입에는 replace_regions_in_mir에서 얻은 보편 영역과 각 영역에 대해 계산된 제약 조건을 포함하여 추론에 필요한 모든 정보가 들어 있습니다. 이는 활성 제약을 계산한 직후에 생성됩니다.
다음은 이 구조체의 필드 중 일부입니다.
constraints: 모든 outlives 제약 조건을 담고 있습니다.liveness_constraints: 모든 활성 제약을 담고 있습니다.universal_regions:replace_regions_in_mir가 반환한UniversalRegions를 담고 있습니다.universal_region_relations: 보편 영역에 대해 참으로 알려진 관계를 담고 있습니다. 예를 들어'a: 'b라는 where 절이 있다면, 구현을 빌림 검사하는 동안 그 관계는 참으로 가정되므로(이는 호출자 쪽에서 검사됩니다)universal_region_relations에는'a: 'b가 포함됩니다.type_tests: 추론 후에 반드시 확인해야 하는 타입에 대한 몇 가지 제약 조건(예:T: 'a)을 담고 있습니다.closure_bounds_mapping: 클로저의 영역 제약 조건을 클로저를 생성한 쪽으로 다시 전파하는 데 사용됩니다.
TODO: 다른 필드도 논의해야 합니까? SCC는 어떻습니까?
자, 이제 RegionInferenceContext를 생성했으므로 추론을 수행할 수 있습니다. 추론은 컨텍스트의 solve 메서드를 호출하여 수행합니다. 앞에서 설명한 것처럼, 이 메서드에서 propagate_constraints를 호출한 다음 그 결과로 나온 타입 테스트와 보편 영역을 확인합니다.
제약 전파
영역 추론의 주요 작업은 제약 전파이며, 이는 propagate_constraints 함수에서 수행됩니다. NLL에서 사용되는 제약에는 세 가지 종류가 있으며, 이 종류의 제약을 한 번에 하나씩 “겹쳐 쌓는” 방식으로 propagate_constraints가 어떻게 동작하는지 설명하겠습니다(각 종류는 다른 종류와 상당히 독립적입니다).
- 활성(liveness)에서 발생하는 활성 제약(
R live at E) - 서브타이핑에서 발생하는 outlives 제약(
R1: R2) - impl 트레이트(
impl Trait)에서 발생하는 멤버 제약 조건(member R_m of [R_c...])입니다.
이 장에서는 활성 제약과 outlives 제약을 모두 다루면서 제약 전파의 “핵심“을 설명하겠습니다.
표기법과 고수준 개념
개념적으로 영역 추론은 “고정점(fixed-point)” 계산입니다. 영역 추론은 제약 집합 {C}를 입력으로 받아, 각 영역 R을 원소 집합 {E}에 대응시키는 값 집합 Values: R -> {E}를 계산합니다(영역 원소에 대한 자세한 내용은 여기를 참고하십시오).
- 처음에는 각 영역이 빈 집합에 대응되므로, 모든 영역
R에 대해Values(R) = {}입니다. - 다음으로, 고정점에 도달할 때까지 제약을 반복적으로 처리합니다.
- 각 제약 C에 대해:
- 제약을 만족시키는 데 필요한 만큼
Values를 갱신합니다
- 제약을 만족시키는 데 필요한 만큼
- 각 제약 C에 대해:
간단한 예로, 활성 제약 R live at E가 있다면 Values(R) = Values(R) union {E}를 적용하여 제약이 만족되도록 할 수 있습니다. 마찬가지로, outlives 제약 R1: R2가 있다면 Values(R1) = Values(R1) union Values(R2)를 적용할 수 있습니다. (멤버 제약은 더 복잡하며 이 절에서 다룹니다.)
그러나 실제로는 조금 더 영리한 방법을 사용합니다. 제약을 루프에서 반복 적용하는 대신, 제약을 분석하여 올바른 적용 순서를 알아낼 수 있습니다. 이렇게 하면 최종 결과를 구하기 위해 각 제약을 한 번만 적용하면 됩니다.
마찬가지로, 구현에서 Values 집합은 scc_values 필드에 저장되지만, 영역이 아니라 강한 연결 요소(SCC)를 기준으로 인덱싱됩니다. SCC는 많은 중복 저장과 계산을 피하기 위한 최적화입니다. 이에 대해서는 outlives 제약에 관한 절에서 설명합니다.
활성 제약
활성 제약은 타입에 영역 R을 포함하는 어떤 변수가 어떤 지점 P에서 활성 상태일 때 발생합니다. 이는 단순히 R의 값이 지점 P를 포함해야 한다는 의미입니다. 활성 제약은 MIR 타입 검사기가 계산합니다.
활성 제약 R live at E는 E가 Values(R)의 원소일 때 만족됩니다. 따라서 이러한 제약을 Values에 “적용“하려면 Values(R) = Values(R) union {E}를 계산하기만 하면 됩니다.
활성 값은 타입 검사 과정에서 계산되어, 영역 추론이 생성될 때 liveness_constraints 인자로 전달됩니다. 다만 이 값들은 R live at E와 같은 개별 제약으로 표현되지 않습니다. 대신 영역 변수마다 (LivenessValues 타입의) (희소) 비트셋을 저장합니다. 이렇게 하면 활성 제약 하나당 비트 하나만 있으면 됩니다.
한 가지 언급할 만한 점이 있습니다. 모든 라이프타임 매개변수는 항상 함수 본문 전체에 걸쳐 활성 상태인 것으로 간주됩니다. 이는 라이프타임 매개변수가 호출자의 실행 중 일부에 해당하고, 호출자가 이 함수의 반환을 기다리고 있으므로 그 실행에는 이 함수에서 소요되는 시간이 분명히 포함되기 때문입니다.
Outlives 제약 조건
outlives 제약 조건 'a: 'b는 'a의 값이 'b 값의 상위 집합이어야 함을 나타냅니다. 즉, outlives 제약 조건 R1: R2는 Values(R1)이 Values(R2)의 상위 집합일 때 충족됩니다. 따라서 이러한 제약 조건을 Values에 “적용“하려면 Values(R1) = Values(R1) union Values(R2)를 계산하기만 하면 됩니다.
이로부터 알 수 있는 한 가지 사실은 R1: R2와 R2: R1이 모두 있으면 R1 = R2가 반드시 참이라는 것입니다. 마찬가지로, 다음과 같은 제약 조건이 있다면:
R1: R2
R2: R3
R3: R4
R4: R1
R1 = R2 = R3 = R4가 성립합니다. 곧 설명하겠지만, 이 성질을 활용하면 작업을 훨씬 빠르게 처리할 수 있습니다.
코드에서 outlives 제약 조건의 집합은 영역 추론 컨텍스트를 생성할 때 OutlivesConstraintSet 타입의 매개변수로 전달됩니다. 이 제약 조건 집합은 기본적으로 'a: 'b 제약 조건의 목록일 뿐입니다.
outlives 제약 조건 그래프와 SCC
outlives 제약 조건을 더 효율적으로 다루기 위해 이 제약 조건들은 그래프 형태로 변환되며, 이 그래프의 노드는 영역 변수('a, 'b)이고 각 제약 조건 'a: 'b는 간선 'a -> 'b를 만듭니다. 이 변환은 추론 컨텍스트를 생성하는 RegionInferenceContext::new 함수에서 이루어집니다.
그래프 표현을 사용하면 순환을 찾아 반드시 같아야 하는 영역을 탐지할 수 있습니다. 즉, 다음과 같은 제약 조건이 있다면
'a: 'b
'b: 'c
'c: 'd
'd: 'a
이는 그래프에서 'a...'d 요소를 포함하는 순환에 해당합니다.
따라서 영역 값을 전파할 때 가장 먼저 하는 일 중 하나는 제약 조건 그래프에서 강한 연결 요소(SCC)를 계산하는 것입니다. 그 결과는 constraint_sccs 필드에 저장됩니다. 그러면 constraint_sccs.scc(r)를 호출하여 영역 r이 속한 SCC를 쉽게 찾을 수 있습니다.
SCC 단위로 작업하면 더 효율적입니다. 하나의 SCC에 속하는 영역 집합 'a...'d가 있다면 각 영역의 값을 따로 계산하거나 저장할 필요가 없습니다. 이 영역들은 모두 같아야 하므로 SCC에 대해 값 하나만 저장하면 됩니다.
영역 추론 코드를 살펴보면 여러 필드가 SCC 단위로 정의되어 있음을 알 수 있습니다. 예를 들어 scc_values 필드는 각 SCC의 값을 저장합니다. 따라서 특정 영역 'a의 값을 얻으려면 먼저 그 영역이 속한 SCC를 알아낸 다음 해당 SCC의 값을 찾습니다.
SCC를 계산할 때는 각 SCC에 어떤 영역이 속하는지뿐만 아니라 SCC 사이의 간선도 알아냅니다. 예를 들어 다음과 같은 outlives 제약 조건 집합을 생각해 봅시다.
'a: 'b
'b: 'a
'a: 'c
'c: 'd
'd: 'c
여기에는 SCC가 두 개 있습니다. S0은 'a와 'b를 포함하고, S1은 'c와 'd를 포함합니다. 그러나 이 SCC들은 서로 독립적이지 않습니다. 'a: 'c이므로 S0: S1도 성립합니다. 즉, S0의 값은 S1 값의 상위 집합이어야 합니다. 한 가지 중요한 점은 이 SCC 그래프가 항상 DAG라는 것, 즉 순환이 전혀 없다는 것입니다. 이는 SCC 자체를 형성하는 과정에서 모든 순환이 제거되었기 때문입니다.
SCC에 활성 제약 적용하기
타입 검사기에서 들어오는 활성 제약은 영역 관점에서 표현됩니다. 즉, Liveness: R -> {E}와 같은 맵을 갖게 됩니다. 그러나 최종 결과는 SCC를 기준으로 표현되어야 합니다. 이러한 활성 제약 조건은 합집합을 취하는 것만으로 매우 쉽게 통합할 수 있습니다.
for each region R:
let S be the SCC that contains R
Values(S) = Values(S) union Liveness(R)
영역 추론기에서 이 단계는 RegionInferenceContext::new에서 수행됩니다.
outlives 제약 조건 적용
SCC의 DAG를 계산하고 나면, 이를 사용하여 전체 계산을 구조화합니다. 두 SCC 사이에 S1 -> S2 간선이 있다면, 이는 Values(S1) >= Values(S2)가 성립해야 함을 의미합니다. 따라서 S1의 값을 계산하려면 먼저 각 후속 SCC S2의 값을 계산합니다. 그런 다음 그 값들을 모두 합집합으로 합치기만 하면 됩니다. 반복자와 유사한 표기법으로 나타내면 다음과 같습니다.
Values(S1) =
s1.successors()
.map(|s2| Values(s2))
.union()
코드에서 이 작업은 모든 SCC를 순회하는 propagate_constraints 함수에서 시작됩니다. 각 SCC S1에 대해, 먼저 후속 SCC들의 값을 계산한 다음 이를 바탕으로 S1의 값을 계산합니다. SCC는 DAG를 이루므로 순환은 걱정할 필요가 없지만, 특정 SCC를 이미 처리했는지 여부를 추적하기 위한 집합은 유지해야 합니다. 각 후속 SCC S2에 대해, S2의 값을 계산하고 나면 그 원소들을 S1의 값에 합집합으로 합칠 수 있습니다. (다만 이 과정에서 고차 플레이스홀더를 올바르게 처리하도록 주의해야 합니다. S1의 값에는 이미 활성 제약 조건이 포함되어 있다는 점에 유의하십시오. 이 제약 조건은 RegionInferenceContext::new에서 추가되었기 때문입니다.
이 과정이 끝나면, 모든 활성 제약 조건과 outlives 제약 조건을 고려한 S1의 “최소값“을 얻게 됩니다. 그러나 이 과정을 완료하려면 이후 절에서 설명하는 멤버 제약 조건도 고려해야 합니다.
보편 영역
“보편 영역(universal regions)“은 코드에서 “이름 있는 라이프타임“을 가리킬 때 사용하는 이름입니다. 라이프타임 매개변수와 'static이 그 예입니다. 이 이름은 이러한 라이프타임이 “보편 양화(universally quantified)“되어 있다는 사실에서 유래합니다(즉, 해당 라이프타임의 모든 값에 대해 코드가 참임을 보장해야 합니다). 영역 추론 중에 라이프타임 매개변수가 어떻게 처리되는지 잠시 살펴볼 필요가 있습니다. 다음 예제를 살펴보십시오.
fn foo<'a, 'b>(x: &'a u32, y: &'b u32) -> &'b u32 {
x
}
이 예제는 일부러 컴파일되지 않게 만든 것입니다. &'a u32 타입인 x를 반환하지만, 시그니처는 &'b u32 값을 반환하겠다고 약속하기 때문입니다. 그렇다면 'a와 'b 같은 라이프타임은 영역 추론에 어떻게 통합되며, 이 오류는 어떻게 감지되는 것입니까?
보편 영역과 그 사이의 관계
영역 추론 초기에 가장 먼저 하는 일 중 하나는 UniversalRegions 구조체를 구성하는 것입니다. 이 구조체는 특정 함수의 스코프 안에 있는 여러 보편 영역을 추적합니다. 또한 UniversalRegionRelations 구조체도 생성하는데, 이 구조체는 보편 영역 사이의 관계를 추적합니다. 따라서 예를 들어 where 'a: 'b가 있다면, UniversalRegionRelations 구조체는 'a: 'b가 성립한다고 알려져 있음을 추적합니다(이는 outlives 함수로 검사할 수 있습니다).
모든 것은 영역 변수입니다
NLL 영역 추론이 작동하는 방식에서 중요한 점 하나는 모든 라이프타임이 번호가 매겨진 변수로 표현된다는 것입니다. 즉, 우리가 사용하는 region_kind::RegionKind의 변형은 ReVar 변형뿐입니다. 이러한 영역 변수는 인덱스에 따라 두 가지 주요 범주로 나뉩니다.
- 0..N: 보편 영역 – 여기서 논의하고 있는 영역입니다. 이 경우 코드는 선언된 관계를 만족하는 해당 변수의 어떠한 값에 대해서도 올발라야 합니다.
- N..M: 존재 영역 – 영역 추론기가 어떤 적절한 값을 찾아내야 하는 추론 변수입니다.
사실 보편 영역은 스코프에 들어온 위치에 따라 더 세분화할 수 있습니다(RegionClassification 타입을 참고하십시오). 이러한 세분화는 여기서 다루는 주제에서는 중요하지 않지만, 클로저 제약 전파를 고려할 때 중요해지므로 해당 부분에서 다룹니다.
영역 값의 원소로서의 보편 라이프타임
앞서 언급했듯이, 각 영역에 대해 추론하는 값은 집합 {E}입니다. 이 집합의 원소는 제어 흐름 그래프의 지점일 수도 있지만, 각 보편 라이프타임 'a에 대응하는 원소 end('a)일 수도 있습니다. 어떤 영역 R0의 값이 end('a)를 포함한다면, 이는 R0가 호출자에서 'a의 끝까지 이어져야 함을 의미합니다.
보편 영역의 “값”
영역 추론 중에는 다른 영역의 값을 계산하는 것과 같은 방식으로 각 보편 영역의 값을 계산합니다. 이 값은 사실상 해당 보편 영역의 하한, 즉 그 영역이 반드시 더 오래 살아야 하는 대상들을 나타냅니다. 이제 이 값을 사용하여 오류를 검사하는 방법을 설명합니다.
활성과 보편 영역
모든 보편 영역은 초기 활성 제약을 가지며, 이 제약은 함수 본문 전체를 포함합니다. 라이프타임 매개변수는 호출자에서 정의되므로, 이 특정 함수를 호출하는 함수 호출 전체를 포함해야 하기 때문입니다. 또한 각 보편 영역 'a는 자신의 활성 제약에 자기 자신(즉, end('a))을 포함합니다(즉, 'a는 자기 자신의 끝까지 이어져야 합니다). 코드에서 이러한 활성 제약은 init_free_and_bound_regions에서 설정됩니다.
보편 영역에 대한 outlives 제약 전파
그러면 이 절의 첫 번째 예제를 살펴보겠습니다.
fn foo<'a, 'b>(x: &'a u32, y: &'b u32) -> &'b u32 {
x
}
여기서 x를 반환하려면 &'a u32 <: &'b u32가 성립해야 하며, 이로부터 outlives 제약 'a: 'b가 생깁니다. 이를 기본 활성 제약과 결합하면 다음을 얻습니다.
'a live at {B, end('a)} // B represents the "function body"
'b live at {B, end('b)}
'a: 'b
따라서 'a: 'b 제약을 처리할 때 'a의 값에 end('b)를 추가하게 되며, 그 결과 최종 값은 {B, end('a), end('b)}가 됩니다.
오류 검출
제약 전파를 마친 후에는, 어떤 보편 영역 'a가 원소 end('b)를 포함한다면 'a: 'b가 함수의 바운드에 선언되어 있어야 한다는 제약을 강제합니다. 우리 예제처럼 선언되어 있지 않다면 이는 오류입니다. 이 검사는 check_universal_regions 함수에서 수행되며, 이 함수는 단순히 모든 보편 영역을 순회하면서 각 영역의 최종 값을 살펴보고 선언된 UniversalRegionRelations에 대해 테스트합니다.
멤버 제약
멤버 제약 'm member of ['c_1..'c_N]는 영역 'm이 어떤 선택 영역 'c_i(어떤 i에 대해)와 같아야 함을 나타냅니다. 이러한 제약은 사용자가 직접 표현할 수 없지만, impl Trait의 라이프타임 캡처 규칙 때문에 impl Trait에서 발생합니다. 다음과 같은 함수를 생각해 보십시오:
fn make(a: &'a u32, b: &'b u32) -> impl Trait<'a, 'b> { .. }
여기서 실제 반환 타입(흔히 “숨겨진 타입“이라고 부릅니다)은 라이프타임 'a 또는 'b만 캡처할 수 있습니다. 해당 impl Trait 반환 타입을 더 명시적인 형태로 디슈가링하면 이를 좀 더 분명하게 볼 수 있습니다:
type MakeReturn<'x, 'y> = impl Trait<'x, 'y>;
fn make(a: &'a u32, b: &'b u32) -> MakeReturn<'a, 'b> { .. }
여기서 핵심은 숨겨진 타입이 impl Trait<'x, 'y> 자리에 쓸 수 있었던 어떤 타입이어야 한다는 것입니다 – 그런데 그러한 타입은 분명히 영역 'x 또는 'y(또는 'static!)만 참조할 수 있습니다. 스코프 안에 있는 이름은 그것들뿐이기 때문입니다. 그런 다음 이 제한은 'a 또는 'b에만 접근할 수 있다는 제약으로 옮겨집니다. 'x와 'y가 각각 'a와 'b로 대체된 MakeReturn<'a, 'b>를 반환하기 때문입니다.
자세한 예제
멤버 제약을 더 자세히 설명하기 위해 make 예제를 좀 더 자세히 풀어 봅시다. 먼저, 다음과 같은 더미 트레이트가 있다고 가정합시다:
trait Trait<'a, 'b> { }
impl<T> Trait<'_, '_> for T { }
그리고 다음은 (디슈가링된 형태의) make 함수입니다:
type MakeReturn<'x, 'y> = impl Trait<'x, 'y>;
fn make(a: &'a u32, b: &'b u32) -> MakeReturn<'a, 'b> {
(a, b)
}
이 경우 반환 타입은 (&'0 u32, &'1 u32)가 되며, 여기서 '0과 '1은 새로운 영역 변수입니다. 다음과 같은 영역 제약이 생깁니다:
'0 live at {L}
'1 live at {L}
'a: '0
'b: '1
'0 member of ['a, 'b, 'static]
'1 member of ['a, 'b, 'static]
여기서 “활성 집합” {L}은 본문 중 '0과 '1이 살아 있는 부분 집합에 해당합니다 – 대략 반환 튜플이 생성되는 지점부터 반환되는 지점까지입니다(사실 '0과 '1의 활성 집합은 약간 다를 수 있지만, 여기서 설명하려는 요점과는 크게 관련이 없습니다).
'a: '0 및 'b: '1 제약은 서브타이핑에서 발생합니다. (a, b) 값을 생성하면 이 값에는 (&'0 u32, &'1 u32) 타입이 할당됩니다 – 영역 변수는 이 참조들의 라이프타임이 더 작아질 수 있음을 반영합니다. 그러나 이 값을 a와 b로부터 생성하려면 다음이 반드시 성립해야 합니다:
(&'a u32, &'b u32) <: (&'0 u32, &'1 u32)
이는 결국 &'a u32 <: &'0 u32이고, 따라서 'a: '0임을 의미합니다(마찬가지로 &'b u32 <: &'1 u32, 'b: '1임을 의미합니다).
멤버 제약을 무시하면 '0의 값은 (명시적으로 적지 않은 활성 제약으로부터) 함수 본문의 어떤 부분 집합으로 추론된다는 점에 유의하십시오. '0은 결코 'a가 되지 않는데, 그럴 필요가 없기 때문입니다 – 'a: '0이라는 제약이 있기는 하지만, 이는 '0이 얼마나 크게 커질 수 있는지에 “상한“을 둘 뿐입니다. 우리는 가능한 최소 값을 계산하므로, '0을 그저 활성 집합과 같은 상태로 두어도 문제가 없습니다. 바로 이 지점에서 멤버 제약이 필요해집니다.
선택지는 항상 라이프타임 매개변수입니다
현재 멤버 제약의 “선택” 영역은 항상 현재 함수의 라이프타임 매개변수입니다. 2026년 3월 현재, 이는 impl Trait(impl 트레이트)가 놓이는 위치에서 자연스럽게 따라 나오는 결과이지만, 앞으로는 그렇지 않을 수도 있습니다. 이 사실 덕분에 현재 코드가 단순해지므로, 우리는 이를 어느 정도 활용합니다. 특히 '0과 '1의 값이 모두 추론 중이어서 변하고 있는 '0 member of ['1, 'static] 같은 경우를 고려할 필요가 없습니다. 자세한 내용은 rust-lang/rust#61773을 참조하십시오.
멤버 제약 적용하기
멤버 제약은 다른 형태의 제약보다 조금 더 복잡합니다. 멤버 제약에는 “또는“의 성격이 있기 때문입니다. 즉, 멤버 제약은 우리가 그중 하나를 골라야 하는 여러 선택지를 기술합니다. 예를 들어 예시 제약 '0 member of ['a, 'b, 'static]에서 '0은 'a, 'b, 또는 'static과 같을 수 있습니다. 올바른 것을 어떻게 고를 수 있겠습니까? 현재 우리가 하는 방식은 최소 선택지를 찾는 것입니다. 최소 선택지를 찾으면 '0을 그 최소 선택지와 같아지도록 키웁니다. 그 최소 선택지를 찾기 위해 우리는 하한과 상한이라는 두 가지 요소를 고려합니다.
하한
하한은 '0이 반드시 더 오래 살아야 하는 라이프타임, 즉 '0이 그보다 커야 하는 라이프타임입니다. 사실 멤버 제약을 적용할 시점이 되면 우리는 이미 '0의 최솟값을 계산했으므로 '0의 하한도 이미 계산해 둔 상태입니다(적어도 멤버 제약을 제외한 모든 것을 고려한 하한은 계산되어 있습니다).
LB를 '0의 현재 값이라고 합시다. 그러면 '0의 최종 값이 무엇이든 '0: LB가 반드시 성립해야 함을 알 수 있습니다. 따라서 'choice: LB가 성립하지 않는 선택지 'choice는 모두 배제할 수 있습니다.
안타깝게도 우리 예시에서는 이것이 그다지 도움이 되지 않습니다. '0의 하한은 그저 활성 집합 {L}일 것이며, 모든 라이프타임 매개변수가 그 집합보다 오래 산다는 것을 우리는 알고 있습니다. 따라서 여기서는 같은 선택지 집합이 그대로 남습니다. (하지만 다른 예시, 특히 변성이 다른 예시에서는 하한 제약이 의미가 있을 수 있습니다.)
상한
상한은 '0보다 반드시 더 오래 살아야 하는 라이프타임, 즉 '0이 그보다 작아야 하는 라이프타임입니다. 우리 예시에서는 'a: '0이라는 제약이 있으므로 상한은 'a가 됩니다. 더 복잡한 예시에서는 이 연쇄가 더 간접적일 수 있습니다.
상한을 사용하면 하한과 매우 비슷한 방식으로 멤버를 배제할 수 있습니다. UB가 어떤 상한이라면 UB: '0이 반드시 성립해야 함을 알 수 있으므로, UB: 'choice가 성립하지 않는 선택지 'choice는 모두 배제할 수 있습니다.
우리 예시에서는 선택지 집합을 ['a, 'b, 'static]에서 ['a]만 남도록 줄일 수 있습니다. '0의 상한이 'a이고, 'a: 'b와 'a: 'static 중 어느 것도 성립한다고 알려져 있지 않기 때문입니다.
(구현에서 상한을 수집하는 방법에 관한 설명은 아래 절을 참조하십시오.)
최소 선택지
하한과 상한을 적용한 뒤에도 여전히 여러 가능성이 남는 경우가 있습니다. 예를 들어 반대 변성을 가진 타입을 사용하는 우리 예시의 변형을 생각해 봅시다. 그 경우에는 'a: '0 대신 '0: 'a라는 제약이 생깁니다. 따라서 '0의 현재 값은 {L, 'a}가 됩니다. 이를 하한으로 사용하면 'b: 'a가 성립한다고 알려져 있지 않으므로(반면 'a: 'a와 'static: 'a는 성립합니다) 멤버 선택지를 ['a, 'static]으로 좁힐 수 있습니다. 상한은 전혀 없으므로 이것이 최종 선택지 집합이 됩니다.
그런 경우에는 최소 선택 규칙을 적용합니다. 간단히 말해, 선택지 중 하나가 다른 것들보다 작다면 그것을 사용할 수 있습니다. 이 경우에는 'static이 아니라 'a를 선택하게 됩니다.
이 선택은 영역 전파의 일반적인 ’흐름’과 일치합니다. 영역 전파는 항상 추론 중인 영역의 최소값을 계산하는 것을 목표로 합니다. 하지만 이 선택은 다소 임의적입니다.
구현에서 상한 수집하기
실제로는 상한을 계산하기가 다소 불편합니다. 우리의 자료 구조가 그 반대 방향에 맞게 구성되어 있기 때문입니다. 우리는 역방향 SCC 그래프를 계산합니다(이 작업은 지연 방식으로 수행하며 결과를 캐시합니다). 즉, 'a: 'b가 SCC('b) -> SCC('a) 간선을 만들어 내는 그래프입니다. 일반 SCC 그래프와 마찬가지로 이 그래프도 DAG입니다. 그런 다음 이 그래프에서 SCC('0)부터 시작하는 깊이 우선 탐색을 수행할 수 있습니다. 이를 통해 '0보다 오래 살아야 하는 모든 SCC에 도달하게 됩니다.
한 가지 까다로운 점은 “상한” SCC들을 순회하는 시점에 그 값들이 아직 완전히 계산되지 않았다는 것입니다. 하지만 그 SCC들의 활성 제약 조건은 이미 적용했으므로, 그 값에 대한 정보를 어느 정도 가지고 있습니다. 특히 라이프타임 매개변수를 나타내는 영역이라면 그 값에는 자기 자신이 포함됩니다(즉, 'a의 초기값에는 'a가 포함되고 'b의 값에는 'b가 포함됩니다). 따라서 도달 가능한 모든 라이프타임 매개변수를 수집할 수 있으며, 이것이 바로 우리가 관심을 두는 것입니다.
플레이스홀더와 유니버스
때로는 구체적으로 알 수 없는 영역에 대해 추론해야 합니다. 예를 들어, 다음 프로그램을 살펴보십시오.
// A function that needs a static reference
fn foo(x: &'static u32) { }
fn bar(f: for<'a> fn(&'a u32)) {
// ^^^^^^^^^^^^^^^^^^^ a function that can accept **any** reference
let x = 22;
f(&x);
}
fn main() {
bar(foo);
}
이 프로그램은 타입 검사를 통과해서는 안 됩니다. foo는 인자로 정적 참조를 요구하고, bar는 어떤 참조든 받아들이는 함수를 전달받기를 원하기 때문입니다(예를 들어, 자신의 스택에 있는 값으로 그 함수를 호출할 수 있도록 하기 위해서입니다). 그렇다면 이 프로그램을 어떻게 거부하며, 왜 거부합니까?
서브타이핑과 플레이스홀더
main을 타입 검사할 때, 특히 bar(foo) 호출을 검사할 때, 결국 다음과 같은 서브타이핑 관계를 얻게 됩니다.
fn(&'static u32) <: for<'a> fn(&'a u32)
---------------- -------------------
the type of `foo` the type `bar` expects
이러한 종류의 서브타이핑은 상위 타입에서 바인딩된 변수들을 가져와 전칭 양화된 대표자로 치환하는 방식으로 처리하며, 여기서는 이를 !1처럼 표기합니다. 이러한 영역을 “플레이스홀더 영역“이라고 부르며, 이는 기본적으로 “알 수 없는 어떤 영역“을 나타냅니다.
이 치환을 마치면 다음과 같은 관계를 얻습니다.
fn(&'static u32) <: fn(&'!1 u32)
여기서 핵심 아이디어는 이 알 수 없는 영역 '!1이 다른 어떤 영역과도 관련이 없다는 것입니다. 따라서 '!1에 대해 서브타이핑 관계가 참임을 증명할 수 있다면, 그 관계는 어떤 영역에 대해서도 참이어야 하며, 이것이 바로 우리가 원하던 바입니다.
그러면 다음에 어떤 일이 일어나는지 차례대로 살펴봅시다. 두 함수가 서브타입 관계인지 확인하기 위해, 두 함수의 인자가 원하는 관계를 가지는지 확인합니다(fn 인자는 반공변이므로 여기서는 왼쪽과 오른쪽을 바꿉니다).
&'!1 u32 <: &'static u32
참조에 대한 기본 서브타이핑 규칙에 따르면, 이는 '!1: 'static인 경우에 참이 됩니다. 즉, “알 수 없는 어떤 영역 !1“이 'static보다 오래 살아남는 경우입니다. 이는 참일 수도 있습니다. 결국 '!1이 'static일 수도 있기 때문입니다. 하지만 그것이 참이라는 것을 알지는 못합니다. 따라서 이는 (결국) 오류를 발생시켜야 합니다.
유니버스란 무엇입니까?
앞 절에서 플레이스홀더 영역이라는 개념을 소개하고, 이를 !1로 표기했습니다. 이 숫자 1을 유니버스 인덱스라고 부릅니다. “유니버스“라는 개념은 어떤 타입 안에서 또는 어떤 시점에 스코프 안에 있는 이름들의 집합을 뜻합니다. 유니버스는 트리 형태로 구성되며, 각 자식은 부모에 새로운 이름 몇 개를 추가하여 확장합니다. 따라서 루트 유니버스는 개념적으로 라이프타임 'static이나 타입 i32와 같은 전역 이름들을 포함합니다. 컴파일러에서는 제네릭 타입 매개변수도 이 루트 유니버스에 넣습니다(이런 의미에서 루트 유니버스는 하나만 있는 것이 아니라 아이템마다 하나씩 있습니다). 그러면 다음 함수 bar를 살펴보십시오.
struct Foo { }
fn bar<'a, T>(t: &'a T) {
...
}
여기서 루트 유니버스는 라이프타임 'static과 'a로 구성됩니다. 사실 여기서는 라이프타임에 초점을 맞추고 있지만, 같은 개념을 타입에도 적용할 수 있으며, 그 경우 타입 Foo와 T가 (i32 같은 다른 전역 타입과 함께) 루트 유니버스에 속하게 됩니다. 기본적으로 루트 유니버스는 bar의 본문에 자유롭게 나타나는 모든 이름을 포함합니다.
이제 변수 x를 추가하여 bar를 조금 확장해 봅시다.
fn bar<'a, T>(t: &'a T) {
let x: for<'b> fn(&'b u32) = ...;
}
여기서 이름 'b는 루트 유니버스에 속하지 않습니다. 대신 이 for<'b>에 “진입“할 때(예를 들어, 이를 플레이스홀더로 치환할 때) 루트의 자식 유니버스를 생성하게 되며, 이를 U1이라고 부르겠습니다.
U0 (root universe)
│
└─ U1 (child universe)
이 자식 유니버스 U1은 루트 유니버스 U0에 새 이름 하나를 더해 확장한 것이며, 이 이름은 유니버스 번호를 사용해 !1로 식별합니다.
이제 변수 y를 하나 더 추가하여 bar를 조금 확장해 봅시다:
fn bar<'a, T>(t: &'a T) {
let x: for<'b> fn(&'b u32) = ...;
let y: for<'c> fn(&'c u32) = ...;
}
이 타입에 진입하면 다시 새 유니버스를 생성하며, 이를 U2라고 부르겠습니다. 이 유니버스의 부모는 루트 유니버스이고, U1은 이 유니버스의 형제가 됩니다:
U0 (root universe)
│
├─ U1 (child universe)
│
└─ U2 (child universe)
이는 U2 안에 있는 동안 U0나 U2에 속한 것은 이름으로 지칭할 수 있지만 U1에 속한 것은 지칭할 수 없다는 뜻입니다.
존재 변수에 유니버스 부여하기. 이제 유니버스라는 개념이 생겼으므로, 이를 사용해 타입 검사기 등을 확장하여 허용되지 않은 이름이 밖으로 새어 나가지 않도록 막을 수 있습니다. 핵심은 타입이든 라이프타임이든 각 추론(존재) 변수에 유니버스를 하나씩 부여한다는 것입니다. 그러면 그 변수의 값은 해당 유니버스에서 보이는 이름만 참조할 수 있습니다. 예를 들어 라이프타임 변수가 U0에서 생성되었다면, 그 변수에는 !1이나 !2 값을 할당할 수 없습니다. 이 이름들은 유니버스 U0에서 보이지 않기 때문입니다.
카운터 하나만으로 유니버스 표현하기. 컴파일러가 유니버스의 전체 트리를 추적하지 않는다는 사실에 놀라실 수도 있습니다. 대신 컴파일러는 카운터 하나만 유지하며, 한 유니버스가 다른 유니버스를 볼 수 있는지 판단할 때는 인덱스가 더 큰지만 확인합니다. 예를 들어 2 >= 0이므로 U2는 U0를 볼 수 있습니다. 하지만 0 >= 2는 거짓이므로 U0는 U2를 볼 수 없습니다.
어떻게 이렇게 해도 문제가 없는 것입니까? 이렇게 하면 U2가 U1도 볼 수 있게 허용하는 것이 아닙니까? 답은, 그렇습니다. 그런 질문이 실제로 제기된다면 허용하게 됩니다. 하지만 타입 검사기 등의 구조상 그런 일이 일어날 방법이 없습니다. 유니버스 U1에서 일어나는 무언가가 U2에서 일어나는 무언가와 “소통“하려면, 둘이 공통으로 공유하는 추론 변수 X가 있어야 합니다. 그리고 U1의 모든 것은 U1과 그 자식으로만 범위가 한정되므로, 그 추론 변수 X는 U0에 있어야 합니다. 그리고 X가 U0에 있으므로, X는 U1(또는 U2)의 어떤 것도 이름으로 지칭할 수 없습니다. 이는 일종의 제네릭한 “논리” 예제를 사용하면 가장 쉽게 이해할 수 있을 것입니다.
exists<X> {
forall<Y> { ... /* Y is in U1 ... */ }
forall<Z> { ... /* Z is in U2 ... */ }
}
여기서 두 forall이 상호 작용할 수 있는 유일한 방법은 X를 통하는 것이지만, X가 선언될 때 Y와 Z는 모두 스코프 안에 있지 않으므로 X의 값은 둘 중 어느 것도 참조할 수 없습니다.
유니버스와 플레이스홀더 영역 요소
그렇다면 그 오류는 어디에서 비롯됩니까? 그 과정은 다음과 같습니다. 영역 추론 컨텍스트를 구성할 때, 타입 추론 컨텍스트로부터 플레이스홀더 변수가 몇 개 존재하는지 알 수 있습니다(InferCtxt에는 내부 카운터가 있습니다). 각 플레이스홀더 변수에 대해 대응하는 보편 영역 변수 !n과 “영역 요소” placeholder(n)을 생성합니다. 이는 “알 수 없는 다른 요소들의 어떤 집합“에 해당합니다. !n의 값은 {placeholder(n)}입니다.
동시에 각 존재 변수에도 유니버스를 부여합니다(이 역시 InferCtxt에서 가져옵니다). 이 유니버스는 변수의 값에 어떤 플레이스홀더 요소가 나타날 수 있는지를 결정합니다. 예를 들어 유니버스 U3에 있는 변수는 placeholder(1), placeholder(2), placeholder(3)은 지칭할 수 있지만 placeholder(4)는 지칭할 수 없습니다. 추론 변수의 유니버스는 그 값에 어떤 리전 요소가 나타날 수 있는지를 제어할 뿐이며, 리전 요소가 반드시 나타난다는 뜻은 아니라는 점에 유의하십시오.
플레이스홀더와 outlives 제약 조건
영역 추론 엔진에서 outlives 제약 조건은 다음과 같은 형태를 가집니다:
V1: V2 @ P
여기서 V1과 V2는 영역 인덱스이므로 어떤 영역 변수(전칭 양화 또는 존재 양화될 수 있음)에 대응됩니다. 여기서 P는 제어 흐름 그래프의 “지점“이며, 이 절에서는 중요하지 않습니다. 이 변수는 유니버스를 가지므로, 그 유니버스들을 각각 U(V1)과 U(V2)라고 부릅시다. (사실 우리가 신경 쓸 것은 U(V1)뿐입니다.)
이 제약 조건을 만나면, 일반적인 절차는 P에서 DFS를 시작하는 것입니다. 순회 중인 노드가 value(V2)에 존재하는 한 계속 순회하며, 그 노드들을 value(V1)에 추가합니다. 반환 지점에 도달하면 모든 end(X) 요소를 추가합니다. 이 부분은 변경되지 않습니다.
하지만 그 다음에는 V2에 있는 플레이스홀더 placeholder(x) 요소들을 순회하려고 합니다(각 요소는 U(V2)에서 보여야 하지만, 이는 확인할 필요 없이 참이라고 그냥 가정할 수 있어야 합니다). value(V1)이 각 플레이스홀더 요소보다 오래 살아남도록 보장해야 합니다.
이제 이것이 일어날 수 있는 방법은 두 가지입니다. 첫째, U(V1)이 유니버스 x를 볼 수 있다면(즉, x <= U(V1)이라면), placeholder(x)를 value(V1)에 추가하기만 하면 됩니다. 그렇지 않다면 근사해야 합니다. placeholder(x)가 어떤 요소 집합을 나타내는지 모를 수도 있지만, 그에 대한 일종의 상한 B, 즉 placeholder(x)보다 오래 살아남는 어떤 영역 B는 계산할 수 있어야 합니다. 지금은 이를 위해 그냥 'static을 사용합니다(모든 것보다 오래 살아남기 때문입니다). 앞으로는 여기서 때때로 더 똑똑하게 처리할 수 있을 것입니다(실제로 다른 맥락에서는 이미 이를 수행하는 코드가 있습니다). 또한 'static은 루트 유니버스 U0에 있으므로 모든 변수가 이를 볼 수 있음을 압니다. 따라서 기본적으로 V1이 볼 수 없는 어떤 유니버스 x에 대해 value(V2)가 placeholder(x)를 포함한다는 것을 발견하면, V1을 'static으로 강제합니다.
“보편 영역” 검사 확장하기
모든 제약이 전파된 후, NLL 영역 추론은 마지막 검사를 하나 수행합니다. 이 검사에서는 각 보편 영역에 대해 최종적으로 계산된 값들을 살펴보고, 그 값들이 ‘너무 커지지’ 않았는지 확인합니다. 우리의 경우에는 각 플레이스홀더 영역을 살펴보면서, 해당 영역이 outlive한다고 알려진 placeholder(u) 요소만 포함하는지 확인합니다. (나중에는 두 플레이스홀더 영역 사이에 관계가 있음을 알아내어 이를 고려할 수 있게 될지도 모릅니다. fn 시그니처에서 오는 보편 영역에 대해 그렇게 하는 것처럼 말입니다.)
다르게 말하면, “보편 영역” 검사는 다음과 같은 제약을 검사하는 것으로 볼 수 있습니다.
{placeholder(1)}: V1
여기서 {placeholder(1)}은 상수 집합과 같고, V1은 !1 영역을 나타내기 위해 만든 변수입니다.
예제로 돌아가기
좋습니다, 지금까지는 순조롭습니다. 이제 첫 번째 예제에서 어떤 일이 일어나는지 살펴봅시다:
fn(&'static u32) <: fn(&'!1 u32) @ P // this point P is not imp't here
영역 추론 엔진은 다음과 같은 영역 요소 도메인을 생성합니다:
{ CFG; end('static); placeholder(1) }
--- ------------ ------- from the universe `!1`
| 'static is always in scope
all points in the CFG; not especially relevant here
이 엔진은 항상 두 개의 전칭 변수를 생성하는데, 하나는 'static을 나타내고 다른 하나는 '!1을 나타냅니다. 이들을 Vs와 V1이라고 부릅시다. 이들은 다음과 같은 초기값을 가집니다:
Vs = { CFG; end('static) } // it is in U0, so can't name anything else
V1 = { placeholder(1) }
위의 서브타이핑 제약 조건으로부터 다음과 같은 outlives 제약 조건을 얻게 됩니다
'!1: 'static @ P
이를 처리하기 위해 V1의 값을 Vs 전체를 포함하도록 키웁니다:
Vs = { CFG; end('static) }
V1 = { CFG; end('static), placeholder(1) }
이 시점에서 모든 outlives 관계가 충족되므로 제약 전파가 완료됩니다. 그런 다음 코드의 “보편 영역 검사” 부분으로 넘어가며, 이 부분에서는 어떤 보편 영역도 너무 커지지 않았는지 테스트합니다.
이 경우 V1은 실제로 너무 커졌습니다. V1은 end('static)보다 오래 산다고 알려져 있지 않고 CFG의 어느 부분보다도 오래 산다고 알려져 있지 않으므로 오류를 보고하게 됩니다.
또 다른 예입니다.
다음 서브타이핑 관계는 어떻습니까?
for<'a> fn(&'a u32, &'a u32)
<:
for<'b, 'c> fn(&'b u32, &'c u32)
여기서는 이전과 마찬가지로 상위 타입의 바운드 영역을 플레이스홀더로 대체하며, 그 결과는 다음과 같습니다:
for<'a> fn(&'a u32, &'a u32)
<:
fn(&'!1 u32, &'!2 u32)
그런 다음 좌변의 변수를 유니버스 U2의 존재 변수로 인스턴스화하며, 그 결과는 다음과 같습니다(?n은 존재 변수를 나타내는 표기입니다):
fn(&'?3 u32, &'?3 u32)
<:
fn(&'!1 u32, &'!2 u32)
그런 다음 이것을 더 분해합니다:
&'!1 u32 <: &'?3 u32
&'!2 u32 <: &'?3 u32
그리고 더 분해하여 영역 제약 조건을 도출합니다:
'!1: '?3
'!2: '?3
이 경우 '!1과 '!2는 모두 변수 '?3보다 오래 살아야 하지만, 변수 '?3은 다른 어떤 것보다도 오래 살도록 강제되지 않는다는 점에 유의하십시오. 따라서 이 변수는 단순히 빈 원소 집합으로 시작하고 끝나며, 그러므로 여기서는 타입 검사가 성공합니다.
(이 결과는 조금 놀라울 것입니다. 저도 이 사실을 처음 깨달았을 때 놀랐습니다. 즉, 우리가 두 인자가 모두 같은 영역을 가져야 하는 fn이라면, 서로 다른 두 영역을 가진 인자로 호출되는 것을 받아들일 수 있다는 뜻입니다. 이는 직관적으로 건전하지 않아 보입니다. 하지만 실제로는 문제가 없으며, 이는 제가 오래전에 Rust 이슈 트래커의 이 이슈에서 설명하려 했던 바와 같습니다. 그 이유는 서로 다른 두 라이프타임의 인자로 호출되더라도 두 라이프타임에는 어떤 교집합(호출 자체)이 존재하며, 그 교집합을 인자들의 공통 라이프타임으로 사용하는 'a의 값으로 삼을 수 있기 때문입니다. -nmatsakis)
마지막 예제
마지막 예제를 하나 살펴봅시다. 이전 예제에 반환 타입을 추가하여 확장하겠습니다:
for<'a> fn(&'a u32, &'a u32) -> &'a u32
<:
for<'b, 'c> fn(&'b u32, &'c u32) -> &'b u32
이전 예제와 매우 비슷해 보이지만, 이 경우에는 오류가 발생합니다. 이는 바람직한 결과입니다. 문제는 두 인자 중 하나를 반환하겠다고 약속하는 fn에서 첫 번째 인자를 반환하겠다고 약속하는 fn으로 바뀌었다는 점입니다. 이는 건전하지 않습니다. 이것이 어떻게 진행되는지 살펴봅시다.
먼저 상위 타입의 바운드 영역을 플레이스홀더로 대체합니다:
for<'a> fn(&'a u32, &'a u32) -> &'a u32
<:
fn(&'!1 u32, &'!2 u32) -> &'!1 u32
그런 다음 서브타입을 (U2 안의) 존재 변수로 인스턴스화합니다.
fn(&'?3 u32, &'?3 u32) -> &'?3 u32
<:
fn(&'!1 u32, &'!2 u32) -> &'!1 u32
이제 서브타이핑 관계를 생성합니다:
&'!1 u32 <: &'?3 u32 // arg 1
&'!2 u32 <: &'?3 u32 // arg 2
&'?3 u32 <: &'!1 u32 // return type
마지막으로 outlives 관계입니다. 여기서 V1, V2, V3를 각각 !1, !2, ?3에 할당하는 변수라고 합시다:
V1: V3
V2: V3
V3: V1
이 변수들은 다음과 같은 초깃값을 갖습니다:
V1 in U1 = {placeholder(1)}
V2 in U2 = {placeholder(2)}
V3 in U2 = {}
이제 V3: V1 제약 조건 때문에 placeholder(1)을 V3에 추가해야 하며(실제로 이는 V3에서 보입니다), 그 결과는 다음과 같습니다:
V3 in U2 = {placeholder(1)}
그런 다음 V2: V3 제약 조건이 있으므로, 결국 V2를 확장하여 placeholder(1)을 포함하도록 해야 합니다(V2에서도 이것이 보입니다):
V2 in U2 = {placeholder(1), placeholder(2)}
이제 제약 전파는 완료되었지만, outlives 관계를 검사할 때 V2에 새로운 요소 placeholder(1)이 포함되어 있음을 발견하므로 오류를 보고합니다.
클로저 제약 전파
클로저 본문 안에서 타입 테스트와 보편 영역(universal region)을 검사하다 보면 아직 증명할 수 없는 제약을 만날 수 있습니다! 하지만 필요한 제약이 실제로는 성립할 수도 있습니다(아직 그 사실을 모를 뿐입니다). 따라서 클로저 내부에 있다면 아직 증명할 수 없는 제약을 모두 수집해서 반환하기만 합니다. 나중에 클로저를 생성한 MIR 노드를 빌림 검사할 때 이 제약들이 성립하는지도 함께 검사할 수 있습니다. 그때 제약이 성립함을 증명할 수 없으면 오류를 보고합니다.
구현 방식
RegionInferenceContext::solve 안에서 클로저를 빌림 검사하는 동안, type-outlives 제약과 region-outlives 제약을 지역적으로 증명할 수 없으면 두 제약을 각각 따로 부모에게 전파하려고 시도합니다.
region-outlives 제약
RegionInferenceContext::check_universal_regions가 어떤 outlives 제약 'longer_fr: 'shorter_fr를 증명하지 못하면 fn try_propagate_universal_region_error에서 이를 전파하려고 시도합니다. 이 두 보편 영역은 각각 클로저에 지역적인 영역이거나 외부 영역입니다.
'longer_fr가 지역 보편 영역이라면, 'longer_fr가 그보다 오래 사는, 즉 'longer_fr: 'fr_minus를 만족하는 가장 큰 외부 영역 'fr_minus를 찾습니다. 그러한 영역이 여러 개라면 mutual_immediate_postdominator를 선택합니다. 이는 모든 GLB의 GLB를 반복해서 계산하여 얻은 고정점이며, 자세한 내용은 TransitiveRelation::postdom_upper_bound를 참고하십시오.
'fr_minus가 존재하면 이 영역이 'shorter_fr의 모든 비지역 상한보다 오래 살 것을 요구합니다. 비지역 상한은 언제나 적어도 하나, 즉 'static이 존재합니다.
type-outlives 제약
type-outlives 제약은 check_type_tests에서 증명됩니다. 이 작업은 outlives 그래프를 계산한 뒤에 이루어지며, 이 시점부터 그래프는 변경할 수 없습니다.
클로저 내부에서 fn eval_verify_bound로 증명하지 못한 모든 타입 테스트에 대해 try_promote_type_test를 호출합니다. TypeTest는 type-outlives 바운드 generic_kind: lower_bound를 verify_bound와 함께 나타냅니다. VerifyBound가 lower_bound에 대해 성립하면 제약이 충족됩니다. try_promote_type_test는 verify_bound를 신경 쓰지 않습니다.
이 함수는 먼저 fn try_promote_type_test_subject를 호출합니다. 이 함수는 GenericKind를 받아 더 이상 클로저에 지역적인 어떤 것도 참조하지 않는 ClosureOutlivesSubject로 변환하려고 시도합니다. 이를 위해 해당 타입의 모든 자유 영역을 'static이나 그 자유 영역과 같은 영역 매개변수로 대체합니다. generic_kind에 대체할 수 없는 영역이 포함되어 있으면 이 작업은 실패합니다.
그런 다음 lower_bound를 호출자의 컨텍스트로 승격합니다. 하한이 플레이스홀더와 같으면 'static으로 대체합니다
그런 다음 lower_bound가 그보다 오래 살아야 하는 모든 보편 영역 uv, 즉 빌림 검사가 영역 제약을 추가한 보편 영역을 살펴봅니다. 그리고 이들 각각에 대해, uv보다 오래 산다고 알려진 모든 비지역 보편 영역에 대한 ClosureOutlivesRequirement를 생성합니다.
이 시점에는 이미 클로저의 영역 그래프를 구축했고 그 그래프가 일관적인지는 따로 검사하므로, 여기서는 outlives 제약 uv: lower_bound를 가정할 수도 있습니다.
따라서 T: 'local_infer처럼 증명할 수 없는 type-outlives 바운드가 있으면, 영역 그래프를 사용하여 'a: local_infer를 만족하는 보편 변수 'a로 이동합니다. 'a가 지역 영역이라면, 가정한 outlives 제약을 사용하여 비지역 영역으로 이동합니다.
그런 다음 승격된 타입 테스트 목록을 BorrowCheckResults에 저장합니다. 그런 다음 TypeChecker::prove_closure_bounds에서 클로저의 부모를 빌림 검사하는 동안 이를 적용합니다.
TODO: 이것이 정확히 어떻게 동작하는지 설명해야 합니다 :3
영역 오류 보고
TODO: 이러한 분석 결과로부터 오류를 생성하는 방법을 논의해야 합니다.
2단계 빌림
2단계 빌림은 가변 빌림보다 더 관대한 형태로, vec.push(vec.len())와 같은 중첩된 메서드 호출을 허용합니다. 이러한 빌림은 처음에는 “예약” 단계에서 공유 빌림으로 동작하다가, 이후에 완전한 가변 빌림으로 “활성화“될 수 있습니다.
특정한 암시적 가변 빌림만 2단계 빌림이 될 수 있으며, 소스 코드에 있는 &mut이나 ref mut은 결코 2단계 빌림이 되지 않습니다. 2단계 빌림을 생성하는 경우는 다음과 같습니다:
- 가변 참조 리시버를 받는 메서드를 호출할 때의 자동 참조(autoref) 빌림.
- 함수 인자에서의 가변 재빌림(reborrow).
- 오버로드된 복합 대입 연산자에서의 암시적 가변 빌림.
몇 가지 예를 들면 다음과 같습니다:
#![allow(unused)]
fn main() {
// In the source code
// Case 1:
let mut v = Vec::new();
v.push(v.len());
let r = &mut Vec::new();
r.push(r.len());
// Case 2:
std::mem::replace(r, vec![1, r.len()]);
// Case 3:
let mut x = std::num::Wrapping(2);
x += x;
}
2단계 빌림이 드러나도록 이를 충분히 전개하면 다음과 같습니다:
// Case 1:
let mut v = Vec::new();
let temp1 = &two_phase v;
let temp2 = v.len();
Vec::push(temp1, temp2);
let r = &mut Vec::new();
let temp3 = &two_phase *r;
let temp4 = r.len();
Vec::push(temp3, temp4);
// Case 2:
let temp5 = &two_phase *r;
let temp6 = vec![1, r.len()];
std::mem::replace(temp5, temp6);
// Case 3:
let mut x = std::num::Wrapping(2);
let temp7 = &two_phase x;
let temp8 = x;
std::ops::AddAssign::add_assign(temp7, temp8);
빌림이 2단계가 될 수 있는지는 타입 검사 이후 AutoBorrow의 플래그로 추적되며, 이 플래그는 MIR 구성 중에 BorrowKind로 변환됩니다.
각 2단계 빌림은 한 번만 사용되는 임시 변수에 대입됩니다. 따라서 다음과 같이 정의할 수 있습니다:
- 임시 변수에 대입되는 지점을 2단계 빌림의 예약 지점이라고 합니다.
- 임시 변수가 사용되는 지점은 사실상 항상 함수 호출이며, 이 지점을 활성화 지점이라고 합니다.
활성화 지점은 GatherBorrows 방문자를 사용하여 찾습니다. 그런 다음 BorrowData가 해당 빌림의 예약 지점과 활성화 지점을 모두 보관합니다.
2단계 빌림 검사하기
2단계 빌림은 다음과 같은 예외를 제외하면 가변 빌림인 것처럼 취급됩니다:
- MIR의 모든 위치에서 해당 위치에서 활성화되는 2단계 빌림이 있는지 검사합니다. 살아 있는 2단계 빌림이 어떤 위치에서 활성화되면, 그 2단계 빌림과 충돌하는 빌림이 없는지 검사합니다.
- 예약 지점에서는 충돌하는 살아 있는 가변 빌림이 있으면 오류를 발생시킵니다. 그리고 충돌하는 공유 빌림이 있으면 린트를 발생시킵니다.
- 예약 지점과 활성화 지점 사이에서 2단계 빌림은 공유 빌림으로 동작합니다. 현재 그러한 지점에 있는지는 (
is_active에서) MIR 그래프의Dominators를 사용하여 판단합니다. - 활성화 지점 이후에는 2단계 빌림이 가변 빌림으로 동작합니다.
빌림 검사기 디버깅
영역 제약 그래프와 강한 연결 요소
-Z dump-mir-graphviz=yes를 사용하면 요청한 MIR 본문의 outlives 제약에 대한 Graphviz 파일과 함께 그 제약들의 강한 연결 요소(SCC)에 대한 Graphviz 파일도 얻을 수 있습니다. 이 파일들은 각각 mir_dump/rs-file-name.function-name.-------.nll.0.regioncx.all.dot과 mir_dump/rs-file-name.function-name.-------.nll.0.regioncx.scc.dot로 제공됩니다. 두 그래프 모두에서 이름이 있는 영역 변수는 외부 이름(예: 'static)이 괄호 안에 함께 표시됩니다. 루트 유니버스가 아닌 유니버스에 속한 영역 추론 변수는 /U13(유니버스 13의 경우)과 같이 표시됩니다. 영역 그래프에서 간선에는 해당 관계가 성립해야 하는 MIR 위치가 레이블로 붙으며, 제약이 항상 참이어야 하는 경우에는 All이 붙습니다.
참고: 'static에서 모든 영역으로 향하는 암시적 간선이 존재하지만, 그래프가 복잡해지는 것을 피하기 위해 영역 그래프에는 그려지지 않습니다. 그러나 SCC 그래프에는 이 간선들이 표시됩니다. 이 때문에 SCC 그래프에는 위의 영역 outlives 그래프에 대응하는 간선이 없는 것처럼 보이는, SCC(5)에서 나가는 간선이 있습니다.
함께 보기
데이터 흐름 디버깅에 관한 일반 지침은 빌림 검사 데이터로 생성된 그래프에도 적용됩니다.
클로저 캡처 추론
이 절에서는 rustc가 클로저를 처리하는 방식을 설명합니다. Rust의 클로저는 실질적으로, 클로저를 생성한 쪽의 스택 프레임에서 가져와 사용하는 값(또는 그 값에 대한 참조)을 담는 구조체로 “디슈가링“됩니다. rustc는 클로저가 어떤 값을 어떻게 사용하는지 파악해야 합니다. 그래야 주어진 변수를 공유 참조로 캡처할지, 가변 참조로 캡처할지, 아니면 이동(move)으로 캡처할지 결정할 수 있습니다. 또한 rustc는 클로저가 클로저 트레이트(Fn, FnMut, FnOnce) 가운데 어느 것을 구현할 수 있는지도 파악해야 합니다.
몇 가지 예제부터 살펴봅시다.
예제 1
먼저 다음 예제의 클로저가 어떻게 디슈가링되는지 살펴봅시다.
fn closure(f: impl Fn()) {
f();
}
fn main() {
let x: i32 = 10;
closure(|| println!("Hi {}", x)); // The closure just reads x.
println!("Value of x after return {}", x);
}
위 코드가 immut.rs라는 파일의 내용이라고 가정하겠습니다. 다음 명령으로 immut.rs를 컴파일한다고 합시다. -Z dump-mir=all 플래그를 사용하면 rustc가 MIR을 생성하여 mir_dump라는 디렉터리에 덤프합니다.
> rustc +stage1 immut.rs -Z dump-mir=all
이 명령을 실행하면 현재 작업 디렉터리에 mir_dump라는 디렉터리가 새로 생성되며, 그 안에 여러 파일이 들어 있습니다. rustc.main.-------.mir_map.0.mir 파일을 살펴보면 여러 내용 가운데 다음 줄도 들어 있음을 확인할 수 있습니다.
_4 = &_1;
_3 = [closure@immut.rs:7:13: 7:36] { x: move _4 };
이 장의 MIR 예제에서 _1은 x라는 점에 유의하십시오.
여기 첫 번째 줄 _4 = &_1;에서 mir_dump는 x가 불변 참조로 빌려졌음을 알려 줍니다. 클로저가 x를 읽기만 하므로 이는 기대한 결과입니다.
예제 2
다른 예제를 살펴보겠습니다.
fn closure(mut f: impl FnMut()) {
f();
}
fn main() {
let mut x: i32 = 10;
closure(|| {
x += 10; // The closure mutates the value of x
println!("Hi {}", x)
});
println!("Value of x after return {}", x);
}
_4 = &mut _1;
_3 = [closure@mut.rs:7:13: 10:6] { x: move _4 };
이번에는 _4 = &mut _1; 줄에서 빌림이 가변 빌림으로 바뀐 것을 볼 수 있습니다. 당연한 결과입니다! 클로저가 x를 10만큼 증가시키기 때문입니다.
예제 3
예제를 하나 더 살펴보겠습니다.
fn closure(f: impl FnOnce()) {
f();
}
fn main() {
let x = vec![21];
closure(|| {
drop(x); // Makes x unusable after the fact.
});
// println!("Value of x after return {:?}", x);
}
_6 = [closure@move.rs:7:13: 9:6] { x: move _1 }; // bb16[3]: scope 1 at move.rs:7:13: 9:6
여기서는 x가 클로저 안으로 직접 이동되므로, 클로저 이후에는 x에 접근할 수 없습니다.
컴파일러의 추론
이제 rustc 코드를 깊이 들여다보며 컴파일러가 이러한 추론을 모두 어떻게 수행하는지 살펴봅시다.
이후 논의에서 자주 사용할 용어인 upvar를 정의하는 것부터 시작합시다. upvar는 클로저가 정의된 함수에 지역적인 변수입니다. 따라서 위 예제들에서 x는 클로저의 upvar가 됩니다. upvar는 클로저의 컨텍스트에 바인딩되지 않는다는 의미에서 자유 변수라고도 불립니다. compiler/rustc_passes/src/upvars.rs는 이를 위해 upvars_mentioned라는 쿼리를 정의합니다.
지연 호출 외에도 클로저가 일반 함수와 다른 점이 하나 더 있습니다. 클로저는 upvar를 사용할 수 있습니다. 클로저는 이러한 upvar를 주변 컨텍스트에서 빌려 오므로 컴파일러는 upvar의 빌림 타입을 결정해야 합니다. 컴파일러는 먼저 불변 빌림 타입을 할당한 뒤 사용 방식에 따라 필요한 만큼 제약을 완화합니다(즉, 불변에서 가변으로, 다시 이동으로 바꿉니다). 위의 예제 1에서 클로저는 변수를 출력하는 데에만 사용하고 전혀 수정하지 않습니다. 따라서 mir_dump에서 upvar x의 빌림 타입이 불변임을 확인할 수 있습니다. 그러나 예제 2에서는 클로저가 x를 수정하여 어떤 값만큼 증가시킵니다. 이 변경 때문에 처음에 x를 불변 참조 타입으로 할당했던 컴파일러는 이를 가변 참조로 조정해야 합니다. 마찬가지로 세 번째 예제에서는 클로저가 벡터를 드롭하므로 변수 x가 클로저 안으로 이동되어야 합니다. 클로저는 빌림 종류에 따라 알맞은 트레이트를 구현해야 합니다. 불변 빌림에는 Fn 트레이트를, 가변 빌림에는 FnMut를, 이동 의미론에는 FnOnce를 구현합니다.
클로저와 관련된 코드는 대부분 compiler/rustc_hir_typeck/src/upvar.rs 파일에 있으며, 자료 구조는 compiler/rustc_middle/src/ty/mod.rs 파일에 선언되어 있습니다.
더 나아가기 전에 rustc 코드베이스에서 제어 흐름을 살펴보는 방법을 알아봅시다. 클로저의 경우에는 아래와 같이 RUSTC_LOG 환경 변수를 설정하고 출력을 파일에 모으십시오.
> RUSTC_LOG=rustc_hir_typeck::upvar rustc +stage1 -Z dump-mir=all \
<.rs file to compile> 2> <file where the output will be dumped>
이 명령은 stage1 컴파일러를 사용하며 rustc_hir_typeck::upvar 모듈에 대해 debug! 로깅을 활성화합니다.
다른 방법은 lldb나 gdb를 사용하여 코드를 한 단계씩 실행해 보는 것입니다.
rust-lldb build/host/stage1/bin/rustc test.rs- lldb에서:
b upvar.rs:134// upvar.rs 파일의 특정 줄에 중단점을 설정합니다r// 중단점에 도달할 때까지 프로그램을 실행합니다
upvar.rs부터 시작합시다. 이 파일에는 euv::ExprUseVisitor라는 것이 있습니다. 이것은 클로저의 소스를 순회하면서 빌려지거나 변경되거나 이동되는 upvar마다 콜백을 호출합니다.
fn main() {
let mut x = vec![21];
let _cl = || {
let y = x[0]; // 1.
x[0] += 1; // 2.
};
}
위의 예제에서 방문자는 1과 2로 표시된 줄에 대해 두 번 호출됩니다. 한 번은 공유 빌림에 대해, 다른 한 번은 가변 빌림에 대해 호출됩니다. 방문자는 무엇이 빌려졌는지도 알려 줍니다.
콜백은 Delegate 트레이트를 구현하여 정의합니다. InferBorrowKind 타입은 Delegate를 구현합니다. 또한 각 upvar에 어떤 캡처 모드가 필요했는지를 기록하는 맵을 유지합니다. 캡처 모드는 ByValue(이동됨) 또는 ByRef(빌려짐)일 수 있습니다. ByRef 빌림의 경우 가능한 BorrowKind 값은 compiler/rustc_middle/src/ty/mod.rs에 정의된 대로 ImmBorrow, UniqueImmBorrow, MutBorrow입니다.
Delegate는 몇 가지 메서드(각기 다른 콜백)를 정의합니다. 변수의 이동에는 consume을, 어떤 종류(공유 또는 가변)의 빌림에는 borrow를, 무언가의 대입을 만났을 때는 mutate를 사용합니다.
이 모든 콜백에는 공통 인자 cmt가 있습니다. 이 이름은 Category, Mutability, Type의 약자이며, 이 인자는 compiler/rustc_hir_typeck/src/expr_use_visitor.rs에 정의되어 있습니다. 코드 주석을 인용하면 “cmt는 값의 완전한 분류로서, 값이 어디에서 비롯되었고 어떻게 위치하는지, 그리고 값이 저장된 메모리의 가변성을 나타냅니다”. 콜백(소비, 빌림 등)에 따라 관련 adjust_upvar_borrow_kind_for_<something>를 호출하고 cmt를 함께 전달합니다. 빌림 타입이 조정되면 이를 테이블에 저장합니다. 이 테이블은 기본적으로 각 클로저에서 어떤 빌림이 이루어졌는지를 나타냅니다.
self.tables
.borrow_mut()
.upvar_capture_map
.extend(delegate.adjust_upvar_captures);
async 클로저/“코루틴-클로저”
이 기능의 전반적인 동기를 이해하려면 RFC 3668을 읽어 보십시오. 이 장은 매우 기술적이며 다소 “수직적인” 장입니다. 이상적으로는 이 내용을 나누어 관련된 모든 장에 흩어 놓아야 하겠지만, async 클로저를 전체적으로 이해하기 위해 이 장 하나에 모두 모아 두었습니다.
코루틴-클로저 – 기술적 심층 분석
코루틴-클로저는 async 클로저를 일반화한 것으로, 코루틴을 반환하는 클로저 표현식을 위한 특수 구문입니다. 특히 이 코루틴은 클로저의 upvar로부터 캡처할 수 있습니다.
현재 사용할 수 있는 코루틴-클로저의 종류는 async 클로저뿐이며, async 클로저를 지원하는 것이 이 PR의 범위입니다. 향후에는 gen || {} 등도 지원할 수 있으며, 이 문서에서 설명하는 문제와 특이점 대부분은 코루틴-클로저 전반에 적용됩니다.
코드가 다소 일반적으로 작성되어 있기 때문에, 이 문서에서는 이를 “async 클로저“와 “코루틴-클로저“라는 이름으로 번갈아 부를 수 있습니다. async 클로저가 반환하는 future는 일반적으로 “코루틴” 또는 “자식 코루틴“이라고 부릅니다.
HIR
async 클로저(그리고 향후에는 gen과 같은 다른 코루틴 종류)는 HIR에서 hir::Closure로 표현됩니다. hir::Closure의 클로저 종류는 ClosureKind::CoroutineClosure(_)1이며, 이는 async 블록을 감쌉니다. 이 async 블록 역시 HIR에서 hir::Closure로 표현됩니다. async 블록의 클로저 종류는 ClosureKind::Closure(CoroutineKind::Desugared(_, CoroutineSource::Closure))2입니다.
async fn과 마찬가지로, async 클로저의 본문을 로워링할 때는 클로저의 모든 인자가 캡처되도록 무조건 본문 안으로 이동시켜야 합니다. 이 작업은 lower_coroutine_body_with_moved_arguments3가 처리합니다. 이 함수에서 주목할 만한 유일한 특이점은 최종적으로 생성되는 async 블록의 캡처 종류가 CaptureBy::ByRef4라는 점입니다. 이후에 모든 클로저 인자가 값으로 캡처되도록 강제하지만5, async 블록 전체가 async move인 것처럼 동작하기를 원하지는 않습니다. 그렇게 되면 async 클로저의 자기 빌림이라는 목적이 무의미해지기 때문입니다.
rustc_middle::ty 표현
구현을 대체로 향후 호환 가능하게(즉, gen || {} 및 async gen || {}와 호환되도록) 유지하기 위해, 이 절의 대부분에서는 async 클로저를 “코루틴-클로저“라고 부릅니다.
이 PR이 도입하는 핵심은 CoroutineClosure6라는 새로운 TyKind와, typeck 및 borrowck의 다른 관련 열거형(UpvarArgs, DefiningTy, AggregateKind)에 추가된 대응 변형입니다.
타입 표현 방식에 큰 차이가 있기 때문에, 기존의 TyKind::Closure를 일반화하는 대신 새로운 TyKind를 도입합니다. CoroutineClosure의 주요 차이점은 먼저 코루틴-클로저의 제네릭을 “풀어 놓은” 표현인 CoroutineClosureArgsParts를 살펴보면 알 수 있습니다.
클로저와의 유사점
클로저와 마찬가지로 parent_args, closure_kind_ty, tupled_upvars_ty가 있습니다. 이들은 클로저에서 대응되는 요소와 같은 것을 나타냅니다. 즉, 클로저가 정의된 본문으로부터 상속받은 제네릭, 클로저의 최대 “호출 능력”(즉, FnOnce처럼 호출하려면 소비되어야 하는지, 아니면 참조로 호출할 수 있는지), 그리고 클로저 자체가 캡처한 upvar입니다.
시그니처
기존 클로저는 클로저의 시그니처를 표현하는 데 사용하는 fn_sig_as_fn_ptr_ty를 가지고 있습니다. 이와 달리, 코루틴 클로저의 시그니처는 다소 “분해된” 방식으로 저장합니다. 코루틴-클로저는 어떤 AsyncFn* 트레이트로 호출하느냐에 따라 두 가지 시그니처를 가지기 때문입니다(아래 절 참조).
개념적으로, 코루틴-클로저는 참조로 호출되는지 이동으로 호출되는지에 따라 여러 가지 다른 시그니처 타입을 포함하는 것으로 생각할 수 있습니다.
이 두 시그니처를 편리하게 재구성할 수 있도록, signature_parts_ty는 이 코루틴-클로저가 반환하는 코루틴의 관련 부분을 모두 저장합니다. 이 시그니처 부분 타입은 대체로 fn(tupled_inputs, resume_ty) -> (return_ty, yield_ty) 형태를 가지며, 여기서 resume_ty, return_ty, yield_ty는 각각 코루틴-클로저가 반환하는 코루틴의 해당 타입입니다7.
컴파일러는 주로 CoroutineClosureSignature 타입8을 다룹니다. 이 타입은 위에서 설명한 fn() 포인터 타입에서 관련 타입을 추출하여 생성되며, 코루틴-클로저가 최종적으로 반환하는 코루틴을 구성하는 데 사용할 수 있는 메서드를 제공합니다.
Coroutine 반환 타입을 구성하기 위해 함께 전달해야 하는 데이터
반환할 TyKind::Coroutine을 구성하려면 시그니처에 저장된 데이터와 함께 코루틴의 “증인(witness)“도 저장해야 합니다.
그렇다면 반환되는 Coroutine의 upvar는 어떻습니까? AsyncFnOnce(즉, 이동에 의한 호출)의 경우, 이는 단순히 코루틴이 반환하는 것과 동일한 upvar입니다. 그러나 AsyncFnMut/AsyncFn의 경우, 코루틴-클로저가 반환하는 코루틴은 주어진 “환경” 라이프타임9으로 코루틴-클로저의 데이터를 빌림합니다. 이는 AsyncFnMut/AsyncFn 호출 시그니처의 &self 라이프타임10, 그리고 ByRef11의 GAT 라이프타임에 해당합니다.
코루틴 반환 타입을 실제로 얻기
코루틴-클로저가 반환하는 Coroutine을 가장 쉽게 구성하려면 CoroutineClosureSignature의 to_coroutine_given_kind_and_upvars12 헬퍼를 사용하면 됩니다. CoroutineClosureSignature는 CoroutineClosureArgs에서 얻을 수 있습니다.
이 함수의 인자 대부분은 CoroutineArgs에서 얻을 수 있는 구성 요소이지만, goal_kind: ClosureKind는 예외입니다. 이 인자는 전달된 ClosureKind에 따라 어떤 종류의 코루틴을 반환할지 제어합니다. 즉, ClosureKind::Fn | ClosureKind::FnMut이면 참조에 의한(by-ref) 코루틴을, ClosureKind::FnOnce이면 이동에 의한(by-move) 코루틴을 준비합니다.
트레이트 계층 구조
에 대해 구현되는 Fn* 트레이트와 병렬적인 계층 구조를 도입합니다. 이를 도입한 동기는 블로그 글 async 클로저에서 다루었습니다.
현재 안정화된 모든 호출 가능 타입(즉, 클로저, 함수 항목, 함수 포인터, dyn Fn* 트레이트 객체)은 어떤 출력 타입 Fut에 대해 Fn*() -> Fut을 구현하고 Fut이 Future<Output = T>을 구현하면 자동으로 AsyncFn*() -> T을 구현합니다13.
async 클로저는 본문이 허용하는 범위에서 AsyncFn*을 구현합니다. 즉, upvar를 호환되는 방식으로 사용하는지에 따라 달라집니다(즉, upvar를 소비하거나 변경하면 AsyncFn 및 AsyncFnMut을 구현하는지 여부에 영향을 줄 수 있습니다…).
대여(Lending)
향후 AsyncFn*을 보다 일반적인 LendingFn* 트레이트 집합으로 옮길 수도 있습니다. 그러나 현재 컴파일러에서 LendingFn을 편리하게 사용하는 데 제약이 되는 구체적인 기술적 구현 세부 사항이 몇 가지 있습니다. 이는 다음과 관련이 있습니다:
- 클로저 시그니처 추론.
- 고차 트레이트 바운드와 관련된 제한 사항입니다.
- 오류 메시지의 단점.
이러한 제약에 더해, 기반 트레이트가 async 클로저와 async Fn 트레이트 바운드의 사용자 경험에 아무런 영향을 주지 않아야 한다는 점 때문에 현재로서는 AsyncFn*를 채택합니다. 궁극적으로 이러한 더 일반적인 트레이트로 옮겨 갈 수 있도록, AsyncFn* 트레이트의 정확한 정의(연관 타입 포함)는 구현 세부 사항으로 남겨 둡니다.
async 클로저는 언제 일반 Fn* 트레이트를 구현합니까?
앞에서 “일반” 호출 가능 타입이 AsyncFn*를 구현할 수 있다고 언급했지만, 반대로 “async 클로저도 Fn*를 구현할 수 있는가?“라는 질문도 있습니다. 짧게 답하면 “유효할 때“입니다. 즉, AsyncFn/AsyncFnMut에서 반환되었을 코루틴이 부모 코루틴-클로저로부터 “빌려 온” upvar를 실제로 하나도 갖지 않을 때입니다.
자세한 답은 아래의 “follow-up: when do…” 절을 참고하십시오. 전체 답에서는 대부분의 async 클로저가 “그냥 동작“하도록 보장하는 데 쓰이는, 꽤 흥미롭고 충분히 철저하기를 바라는 휴리스틱을 설명합니다.
두 본문 이야기…
async 클로저가 AsyncFn/AsyncFnMut로 호출되면, 클로저로부터 빌림하는 코루틴을 반환합니다. 그러나 AsyncFnOnce를 통해 호출되면 해당 클로저를 소비하므로, 이미 드롭된 데이터로부터 빌림하는 코루틴을 반환할 수 없습니다.
이 제약을 우회하기 위해, 참조로 호출할 수 있는 코루틴-클로저에 대해 AsyncFnOnce::call_once를 호출할 때 쓰는 별도의 by-move MIR 본문을 합성합니다.
이 본문은 코루틴-클로저를 호출하여 반환되는 “일반” 코루틴과 동일하게 동작하지만, 부모 코루틴-클로저의 캡처를 자식 코루틴으로 이동해야 하므로 upvar 집합이 다르다는 점만 다릅니다.
by-move 본문 합성하기
코루틴-클로저가 반환한 코루틴의 by-move 본문에 접근하려면 coroutine_by_move_body_def_id14 쿼리를 사용하면 됩니다.
이 쿼리는 코루틴의 MIR 본문을 복사한 뒤, 본문의 의미를 보존하기 위해 추가적인 역참조와 필드 프로젝션15을 삽입하여 새로운 MIR 본문을 합성합니다.
새로운 def id를 합성했으므로, 이 쿼리는 MIR 본문과 관련된 수많은 다른 쿼리에 값을 공급하는 역할도 맡습니다. 이 쿼리는 코루틴의 빌드된 MIR에 대해 동작하므로 mir_promoted 쿼리 중에 ensure()16됩니다.
클로저 시그니처 추론
async 클로저의 클로저 시그니처 추론 알고리즘은 “전통적인” 클로저의 추론 알고리즘보다 약간 더 복잡합니다. 클로저와 마찬가지로, (전달된 기대 타입에 대해) 관련이 있을 수 있는 모든 절을 순회합니다17.
시그니처를 추출하기 위해 두 가지 상황을 고려합니다.
AsyncFnOnce::Output에 대한 프로젝션 술어로, 이를 사용하여 클로저의 입력 타입과 출력 타입을 추출합니다. 이는F: AsyncFn*() -> T바운드18가 있었던 상황에 해당합니다.FnOnce::Output에 대한 프로젝션 술어로, 이를 사용하여 입력을 추출합니다. 출력의 경우, 관련된Future::Output프로젝션 술어를 찾아 출력을 추론하는 것도 시도합니다. 이는F: Fn*() -> T, T: Future<Output = U>바운드가 있었던 상황에 해당합니다.19Future바운드가 없으면 출력에 대해 단순히 새로운 추론 변수를 사용합니다. 이는Option::map과 같은 조합자 함수에 async 클로저를 전달할 수 있는 경우에 해당합니다.20
후자의 경우를 지원하는 이유는 단순히, 일급 AsyncFn* 트레이트가 도입되기 전에 설계된 API를 호출할 때에도 사용자가 async || {} 구문을 그대로 끼워 넣어 쓸 수 있도록 하기 위해서입니다.
클로저의 종류가 추론되기 전에 클로저 호출하기
코루틴-클로저의 “종류”(즉, 최대 호출 모드: AsyncFnOnce/AsyncFnMut/AsyncFn) 계산은 typeck가 끝날 때까지 미룹니다21. 그러나 typeck가 끝나기 전에 해당 코루틴-클로저를 호출할 수 있어야 하므로, 그 전에 코루틴-클로저의 반환 타입을 결정해야 합니다.
어떤 Fn* 트레이트로 호출하든 반환 타입이 바뀌지 않는 일반 클로저와 달리, 코루틴-클로저는 호출에 사용된 AsyncFn* 트레이트의 종류에 따라 실제로 서로 다른 코루틴 타입을 반환하게 됩니다.
구체적으로, 반환되는 코루틴의 def-id는 바뀌지 않지만, upvar22(부모 코루틴-클로저로부터 빌리거나 이동된 것)와 코루틴 종류23는 호출 모드에 따라 달라집니다.
AsyncFnKindHelper 트레이트를 도입하여, “이 코루틴-클로저가 이 호출 모드를 지원하는가”24라는 질문은 트레이트 목표를 통해, “이 호출 모드의 튜플화된 upvar는 무엇인가”25라는 질문은 연관 타입을 통해 미룰 수 있게 합니다. 이 연관 타입은 코루틴-클로저의 입력 타입을 upvar 분석 중에 계산된 upvar 또는 “참조” upvar에 덧붙여 계산할 수 있습니다.
그렇다면 왜 이렇게 할까요?
이 방식은 다소 우회적이고 복잡해 보이며, 실제로 그렇다는 점을 인정합니다. 하지만 “아무것도 하지 않는” 대안을 생각해 봅시다. 그 대신 upvar 분석까지 모든 AsyncFn* 목표를 모호한 것으로 표시해 둘 수도 있으며, 그 시점에는 반환할 코루틴의 upvar에 정확히 무엇을 넣어야 하는지 알게 됩니다. 그러나 이 방식은 실제로 프로그램의 추론에 매우 해로운데, 다음과 같은 프로그램이 유효하지 않게 되기 때문입니다:
let c = async || -> String { .. };
let s = c().await;
// ^^^ If we can't project `<{c} as AsyncFn>::call()` to a coroutine, then the `IntoFuture::into_future` call inside of the `.await` stalls, and the type of `s` is left unconstrained as an infer var.
s.as_bytes();
// ^^^ That means we can't call any methods on the awaited return of a coroutine-closure, like... at all!
그래서 그 대신 우리는 이 별칭(이 경우에는 프로젝션인 AsyncFnKindHelper::Upvars<'env, ...>)을 사용하여 튜플화된 upvar의 계산을 미루고 그 자리에 넣을 무언가를 마련합니다. 그러면서도 여전히 TyKind::Coroutine(경직된 타입)을 반환할 수 있으며, Future 구현은 upvar에 전혀 의존하지 않으므로 필요한 내장 트레이트(여기서는 Future)도 성공적으로 확인할 수 있습니다.
Upvar 분석
대체로 코루틴-클로저와 그 자식 코루틴에 대한 upvar 분석은 일반적인 upvar 분석과 같이 진행됩니다. 그러나 async 클로저의 특수한 성질을 처리하기 위해 일어나는 몇 가지 흥미로운 부분이 있습니다:
모든 입력을 강제로 캡처하기
async fn과 마찬가지로 모든 입력 인자는 캡처됩니다. async 클로저가 반환하는 future 코루틴이 입력이 본문에서 사용되는지 여부에 의존하지 않도록, 우리는 이러한 모든 입력이 이동으로 캡처되도록 명시적으로 강제합니다26. 그렇지 않으면 흥미로운 semver 위험이 생길 것입니다.
참조에 의한 캡처 계산하기
AsyncFn/AsyncFnMut을 지원하는 코루틴-클로저의 경우, 코루틴-클로저의 캡처와 그 자식 코루틴 사이의 관계도 계산해야 합니다. 구체적으로, 코루틴-클로저는 upvar를 자신의 캡처로 move할 수 있지만, 코루틴은 그 upvar를 빌림만 할 수 있습니다.
자식 코루틴의 모든 캡처를 살펴보고 이를 부모 코루틴-클로저의 대응하는 캡처와 비교하여 “coroutine_captures_by_ref_ty“를 계산합니다27. 이 coroutine_captures_by_ref_ty는 결국 for<'env> fn() -> captures... 타입으로 표현되며, 추가된 바인더 라이프타임은 AsyncFn::async_call 또는 AsyncFnMut::async_call_mut를 호출할 때의 “&self” 라이프타임을 나타냅니다. 이 바인더는 나중에 메서드를 실제로 호출할 때 인스턴스화합니다.
부모 코루틴-클로저에서 참조로 캡처한 것이 모두 “대여(lending)” 빌림이 되는 것은 아니라는 점에 유의하십시오. 이는 코루틴-클로저가 Fn* 계열 트레이트를 구현할 수 있는지 여부에 밀접하게 영향을 미치므로, 자세한 내용은 아래의 후속 논의: async 클로저는 언제 일반 Fn* 트레이트를 구현합니까? 절을 참조하십시오.
이동 방식 본문 + FnOnce의 특이점
클로저 upvar 분석이 코루틴-클로저의 자식 코루틴에 대해 지나치게 느슨한 upvar를 추론하여 결국 빌림 검사기 오류가 발생하는 상황이 몇 가지 있습니다. 이는 예제를 통해 설명하는 것이 가장 좋습니다. 예를 들어 다음과 같은 코드가 있다고 합시다.
#![allow(unused)]
fn main() {
fn force_fnonce<T: async FnOnce()>(t: T) -> T { t }
let x = String::new();
let c = force_fnonce(async move || {
println!("{x}");
});
}
x는 코루틴-클로저로 이동되지만, 반환되는 코루틴은 &x만 빌림합니다. 그러나 force_fnonce가 코루틴-클로저를 대여하지 않는 AsyncFnOnce로 강제하므로, 캡처가 이동 방식으로 일어나도록 강제해야 합니다28.
마찬가지로 다음 코드를 봅시다.
#![allow(unused)]
fn main() {
let x = String::new();
let y = String::new();
let c = async move || {
drop(y);
println!("{x}");
};
}
x는 코루틴-클로저로 이동되지만, 반환되는 코루틴은 &x만 빌림합니다. 그러나 y도 캡처하여 드롭하므로, 코루틴-클로저는 AsyncFnOnce가 되도록 강제됩니다. 이 경우에도 x의 캡처가 이동 방식으로 일어나도록 강제해야 합니다. 특히 이 상황을 판별하려면, 앞의 예제와 달리 코루틴 종류의 클로저 종류가 아직 제약되지 않았으므로, 코루틴-클로저의 본문을 분석하여 모든 upvar가 어떻게 사용되는지 살펴보고 이들이 “소비하는” 방식, 즉 FnOnce로 강제하는 방식으로 사용되었는지 판단해야 합니다29.
후속 논의: async 클로저는 언제 일반 Fn* 트레이트를 구현합니까?
우선, 모든 async 클로저는 항상 최소 한 번은 호출될 수 있으므로 FnOnce를 구현합니다.
Fn/FnMut에 대한 자세한 답을 얻으려면 관련된 질문, 즉 코루틴-클로저가 대여를 하는지에 먼저 답해야 합니다. 만약 대여를 한다면, 대여하지 않는 Fn/FnMut 트레이트를 구현할 수 없기 때문입니다.
코루틴-클로저가 언제 자신의 upvar를 대여해야 하는지 판단하는 로직은 should_reborrow_from_env_of_parent_coroutine_closure 도우미 함수에 구현되어 있습니다30. 구체적으로, 이는 두 가지 경우에 필요합니다.
- 부모 클로저가 소유한 데이터를 빌리고 있습니까? 부모 캡처가 이동 방식인지 확인하여 이 경우에 해당하는지 판단할 수 있습니다. 단, 역참조 프로젝션을 적용하는 경우는 예외인데, 이는 이동 방식으로 캡처한 참조를 재빌림하고 있다는 뜻이기 때문입니다.
#![allow(unused)]
fn main() {
let x = &1i32; // Let's call this lifetime `'1`.
let c = async move || {
println!("{:?}", *x);
// Even though the inner coroutine borrows by ref, we're only capturing `*x`,
// not `x`, so the inner closure is allowed to reborrow the data for `'1`.
};
}
- 코루틴이 부모 캡처로부터 가변으로 빌리고 있다면, 그 가변 빌림은 부모나 원래 upvar에 대해 가진 빌림 중 어느 것보다도 오래 살 수 없습니다. 따라서 자식 캡처는 항상 부모 코루틴-클로저 env의 라이프타임으로 빌림해야 합니다.
#![allow(unused)]
fn main() {
let mut x = 1i32;
let c = async || {
x = 1;
// The parent borrows `x` for some `&'1 mut i32`.
// However, when we call `c()`, we implicitly autoref for the signature of
// `AsyncFnMut::async_call_mut`. Let's call that lifetime `'call`. Since
// the maximum that `&'call mut &'1 mut i32` can be reborrowed is `&'call mut i32`,
// the inner coroutine should capture w/ the lifetime of the coroutine-closure.
};
}
이 두 경우 중 하나라도 해당하면, 부모 코루틴-클로저 env의 라이프타임으로 빌림을 캡처해야 합니다. 다행히 이 함수가 올바르지 않더라도 프로그램이 건전하지 않게 되지는 않습니다. 이 함수가 내린 선택은 여전히 borrowck로 검사하고 검증하기 때문입니다. 유일한 부작용은 사용자가 불필요한 borrowck 오류를 받을 수 있다는 점입니다.
인스턴스 해석
코루틴-클로저의 closure-kind가 FnOnce이면, 그 AsyncFnOnce::call_once 및 FnOnce::call_once 구현은 코루틴-클로저의 본문으로 해석되며31, 반환되는 코루틴의 Future::poll은 자식 클로저의 본문으로 해석됩니다.
코루틴-클로저의 closure-kind가 FnMut/Fn이면, AsyncFn 및 반환되는 코루틴의 해당 Future 구현에도 동일하게 적용됩니다.31 그러나 AsyncFnOnce::call_once/FnOnce::call_once의 구현32과, 존재하는 경우 Fn::call/FnMut::call_mut 인스턴스33를 생성하기 위해서는 MIR shim을 사용합니다.
이는 ConstructCoroutineInClosureShim34으로 표현됩니다. 이것이 Fn::call/FnMut::call_mut의 인스턴스인 경우 receiver_by_ref bool 값은 true가 됩니다.35 이 모든 인스턴스가 반환하는 코루틴은 이 시점까지 합성해 둔 by-move 본문에 해당합니다.36
빌림 검사
async 클로저의 빌림 검사는 상당히 간단한 것으로 드러났습니다. 새로운 DefiningTy::CoroutineClosure37 배리언트를 추가하고 borrowck에 코루틴-클로저의 시그니처를 생성하는 방법을 알려 주고 나면38, borrowck는 아무 문제 없이 진행됩니다.
한 가지 주목할 점은 by-move 코루틴을 위해 만드는 합성 본문은 빌림 검사를 하지 않는다는 것입니다. 구성 방식상(그리고 그 본문이 파생된 by-ref 코루틴 본문의 유효성에 따라) 해당 본문은 반드시 유효하기 때문입니다.
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_ast_lowering/src/expr.rs#L1147 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_ast_lowering/src/expr.rs#L1117 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_ast_lowering/src/item.rs#L1096-L1100 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_ast_lowering/src/item.rs#L1276-L1279 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_hir_typeck/src/upvar.rs#L250-L256 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_type_ir/src/ty_kind.rs#L163-L168 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_type_ir/src/ty_kind/closure.rs#L221-L229 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_type_ir/src/ty_kind/closure.rs#L362 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_type_ir/src/ty_kind/closure.rs#L447-L455 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/library/core/src/ops/async_function.rs#L36 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/library/core/src/ops/async_function.rs#L30 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_type_ir/src/ty_kind/closure.rs#L419 ↩
-
https://github.com/rust-lang/rust/blob/7c7bb7dc017545db732f5cffec684bbaeae0a9a0/compiler/rustc_next_trait_solver/src/solve/assembly/structural_traits.rs#L404-L409 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_mir_transform/src/coroutine/by_move_body.rs#L1-L70 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_mir_transform/src/coroutine/by_move_body.rs#L131-L195 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_mir_transform/src/lib.rs#L339-L342 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_hir_typeck/src/closure.rs#L345-L362 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_hir_typeck/src/closure.rs#L486-L487 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_hir_typeck/src/closure.rs#L517-L534 ↩
-
https://github.com/rust-lang/rust/blob/5ca0e9fa9b2f92b463a0a2b0b34315e09c0b7236/compiler/rustc_hir_typeck/src/closure.rs#L575-L590 ↩
-
https://github.com/rust-lang/rust/blob/705cfe0e966399e061d64dd3661bfbc57553ed87/compiler/rustc_hir_typeck/src/callee.rs#L169-L210 ↩
-
https://github.com/rust-lang/rust/blob/705cfe0e966399e061d64dd3661bfbc57553ed87/compiler/rustc_type_ir/src/ty_kind/closure.rs#L574-L576 ↩
-
https://github.com/rust-lang/rust/blob/705cfe0e966399e061d64dd3661bfbc57553ed87/compiler/rustc_type_ir/src/ty_kind/closure.rs#L554-L563 ↩
-
https://github.com/rust-lang/rust/blob/7c7bb7dc017545db732f5cffec684bbaeae0a9a0/library/core/src/ops/async_function.rs#L135-L144 ↩
-
https://github.com/rust-lang/rust/blob/7c7bb7dc017545db732f5cffec684bbaeae0a9a0/library/core/src/ops/async_function.rs#L146-L154 ↩
-
https://github.com/rust-lang/rust/blob/7c7bb7dc017545db732f5cffec684bbaeae0a9a0/compiler/rustc_hir_typeck/src/upvar.rs#L250-L259 ↩
-
https://github.com/rust-lang/rust/blob/7c7bb7dc017545db732f5cffec684bbaeae0a9a0/compiler/rustc_hir_typeck/src/upvar.rs#L375-L471 ↩
-
https://github.com/rust-lang/rust/blob/7c7bb7dc017545db732f5cffec684bbaeae0a9a0/compiler/rustc_hir_typeck/src/upvar.rs#L211-L248 ↩
-
https://github.com/rust-lang/rust/blob/7c7bb7dc017545db732f5cffec684bbaeae0a9a0/compiler/rustc_hir_typeck/src/upvar.rs#L532-L539 ↩
-
https://github.com/rust-lang/rust/blob/7c7bb7dc017545db732f5cffec684bbaeae0a9a0/compiler/rustc_hir_typeck/src/upvar.rs#L1818-L1860 ↩
-
https://github.com/rust-lang/rust/blob/705cfe0e966399e061d64dd3661bfbc57553ed87/compiler/rustc_ty_utils/src/instance.rs#L351 ↩ ↩2
-
https://github.com/rust-lang/rust/blob/705cfe0e966399e061d64dd3661bfbc57553ed87/compiler/rustc_ty_utils/src/instance.rs#L341-L349 ↩
-
https://github.com/rust-lang/rust/blob/705cfe0e966399e061d64dd3661bfbc57553ed87/compiler/rustc_ty_utils/src/instance.rs#L312-L326 ↩
-
https://github.com/rust-lang/rust/blob/705cfe0e966399e061d64dd3661bfbc57553ed87/compiler/rustc_middle/src/ty/instance.rs#L129-L134 ↩
-
https://github.com/rust-lang/rust/blob/705cfe0e966399e061d64dd3661bfbc57553ed87/compiler/rustc_middle/src/ty/instance.rs#L136-L141 ↩
-
https://github.com/rust-lang/rust/blob/07cbbdd69363da97075650e9be24b78af0bcdd23/compiler/rustc_middle/src/ty/instance.rs#L841 ↩
-
https://github.com/rust-lang/rust/blob/705cfe0e966399e061d64dd3661bfbc57553ed87/compiler/rustc_borrowck/src/universal_regions.rs#L110-L115 ↩
-
https://github.com/rust-lang/rust/blob/7c7bb7dc017545db732f5cffec684bbaeae0a9a0/compiler/rustc_borrowck/src/universal_regions.rs#L743-L790 ↩
MIR에서 바이너리까지
이 가이드의 앞선 모든 장에는 한 가지 공통점이 있습니다. 실행 가능한 기계어 코드를 전혀 생성하지 않았다는 점입니다! 이 장에서는 그 모든 것이 바뀝니다.
지금까지 컴파일러가 텍스트 형식의 원시 소스 코드를 받아 MIR로 변환하는 방법을 살펴보았습니다. 또한 컴파일러가 코드에 대해 다양한 분석을 수행하여 타입 오류나 라이프타임 오류 같은 것을 탐지하는 방법도 살펴보았습니다. 이제 마침내 MIR을 받아 실행 가능한 기계어 코드를 만들어 보겠습니다.
참고: 컴파일러의 이 부분은 흔히 백엔드라고 불립니다. 이 용어는 여러 의미로 쓰이는데, 컴파일러 소스에서는 보통 “코드 생성 백엔드”(즉, LLVM, Cranelift 또는 GCC)를 가리키기 때문입니다. 이 부분에서 “백엔드“라는 단어가 나오면 보통 “코드 생성 백엔드“를 가리키는 것입니다.
그렇다면 무엇을 해야 하겠습니까?
- 먼저 코드를 생성할 대상의 집합을 수집해야 합니다. 특히 구체 타입에 대한 코드를 생성해야 하므로, 제네릭 타입을 어떤 구체 타입으로 대체할지 알아내야 합니다. 구체 타입에 대한 코드를 생성하는 것(즉, 각 구체 타입마다 코드의 사본을 내보내는 것)을 단형화라고 하므로, 모든 구체 타입을 수집하는 과정을 단형화 수집이라고 합니다.
- 다음으로, 수집한 각 구체 타입에 대해 MIR을 코드 생성 IR(보통 LLVM IR)로 실제로 낮춰야 합니다.
- 마지막으로 코드 생성 백엔드를 호출해야 합니다. 코드 생성 백엔드는 여러 최적화 패스를 실행하고, 실행 가능한 코드를 생성하며, 이를 링크하여 실행 가능한 바이너리를 만듭니다.
코드 생성을 위한 코드는 몇 가지 요인 때문에 실제로 다소 복잡합니다:
- 여러 코드 생성 백엔드(LLVM, Cranelift, GCC)를 지원합니다. 백엔드 코드를 가능한 한 많이 공유하려고 하므로, 상당 부분이 코드 생성 구현에 대해 제네릭합니다. 이 때문에 추상화 계층이 여러 겹인 경우가 많습니다.
- 성능을 위해 코드 생성은 다른 스레드에서 비동기적으로 수행됩니다.
- 실제 코드 생성은 서드파티 라이브러리(세 백엔드 중 하나)가 수행합니다.
일반적으로 rustc_codegen_ssa 크레이트에는 백엔드에 독립적인 코드가 있고, rustc_codegen_llvm 크레이트에는 LLVM 코드 생성에 특화된 코드가 있습니다.
아주 높은 수준에서 보면 진입점은 rustc_codegen_ssa::base::codegen_crate입니다. 이 함수는 이 장의 나머지 부분에서 설명하는 과정을 시작합니다.
MIR 최적화
MIR 최적화는 코드 생성 전에 더 나은 MIR을 만들기 위해 MIR에 대해 실행되는 최적화입니다. 이는 두 가지 이유로 중요합니다. 첫째, 최종적으로 생성되는 실행 코드가 더 좋아지고, 둘째, LLVM이 해야 할 작업이 줄어들어 컴파일이 더 빨라집니다. MIR은 제네릭이므로(아직 단형화되지 않았으므로) 이러한 최적화가 특히 효과적이라는 점에 유의하십시오. 제네릭 버전을 최적화할 수 있으므로 모든 단형화 결과의 비용이 줄어듭니다!
MIR 최적화는 빌림 검사 후에 실행됩니다. MIR을 개선하기 위해 MIR에 대해 일련의 최적화 패스를 실행합니다. 일부 패스는 모든 코드에 대해 반드시 실행되어야 하고, 일부 패스는 실제로 최적화를 수행하지 않고 검사만 하며, 일부 패스는 release 모드에서만 활성화됩니다.
주어진 DefId에 대한 최적화된 MIR을 만들기 위해 optimized_mir 쿼리가 호출됩니다. 이 쿼리는 빌림 검사기가 실행되었는지, 그리고 일부 검증이 수행되었는지 확인합니다. 그런 다음 MIR을 훔쳐 와서(steal) 최적화하고, 개선된 MIR을 반환합니다.
새 최적화 추가를 위한 빠른 시작
-
최적화하려는 코드를 보여 주는 Rust 소스 파일을
tests/mir-opt에 만드십시오. 이 파일은 단순하게 유지해야 하므로, 최적화에 필요하지 않다면println!이나 기타 포매팅 코드는 피하십시오. 그 이유는println!,format!등이 많은 MIR을 생성하여 최적화가 테스트에 어떤 영향을 주는지 이해하기 어렵게 만들 수 있기 때문입니다. -
MIR 덤프를 생성하려면
./x test --bless tests/mir-opt/<your-test>.rs를 실행하십시오. 덤프하는 방법은 이 README를 참고하십시오. -
현재 작업 디렉터리 상태를 커밋하십시오. 최적화를 구현하기 전에 테스트 출력을 커밋해야 하는 이유는 여러분(그리고 리뷰어)이 최적화로 무엇이 바뀌었는지 전후 diff로 확인할 수 있도록 하기 위해서입니다.
-
compiler/rustc_mir_transform/src에 새 최적화를 구현하십시오. 이를 가장 빠르고 쉽게 하는 방법은 다음과 같습니다.- 작은 최적화(예:
remove_storage_markers)를 골라 새 파일에 복사하고, run_optimization_passes()함수에 있는 목록 중 하나에 여러분의 최적화를 추가한 다음,- 복사한 최적화를 수정하기 시작하십시오.
- 작은 최적화(예:
-
MIR 덤프를 다시 생성하려면
./x test --bless tests/mir-opt/<your-test>.rs를 다시 실행하십시오. diff를 보고 예상한 대로인지 확인하십시오. -
최적화가 무언가를 망가뜨렸는지 확인하려면
./x test tests/ui를 실행하십시오. -
최적화에 문제가 있으면 조금 실험해 보고 5단계와 6단계를 반복하십시오.
-
커밋하고 PR을 여십시오. 아직 제대로 동작하지 않더라도 언제든지 이 작업을 할 수 있으며, 그러면 PR에서 피드백을 요청할 수 있습니다. 그런 경우에는 “WIP” PR을 여십시오(PR 제목 앞에
[WIP]를 붙이거나 다른 방식으로 작업 중임을 표시하면 됩니다).bless된 테스트 출력도 반드시 커밋하십시오! 이는 CI를 통과하는 데 필요하며 리뷰어에게도 매우 도움이 됩니다.
진행하다가 궁금한 점이 있으면 언제든지 Zulip의 #t-compiler/wg-mir-opt에서 질문하십시오.
최적화 패스 정의하기
실행되는 패스의 목록과 그 실행 순서는 run_optimization_passes 함수에서 정의됩니다. 이 함수에는 실행할 패스의 배열이 들어 있습니다. 배열의 각 패스는 MirPass 트레이트를 구현하는 구조체입니다. 이 배열은 &dyn MirPass 트레이트 객체의 배열입니다. 일반적으로 패스는 rustc_mir_transform 크레이트 안의 전용 모듈에 구현됩니다.
패스의 몇 가지 예는 다음과 같습니다.
CleanupPostBorrowck: 코드 생성이 아니라 분석에만 필요한 정보의 일부를 제거합니다.ConstProp: 상수 전파를 수행합니다.
더 많은 예는 MirPass rustdocs의 “Implementors” 섹션에서 볼 수 있습니다.
MIR 최적화 수준
MIR 최적화는 완성도에 따라 여러 수준으로 나뉠 수 있습니다. 실험적인 최적화는 잘못된 컴파일을 일으키거나 컴파일 시간을 늘릴 수 있습니다. 이러한 패스는 피드백을 수집하고 패스를 더 쉽게 수정할 수 있도록 여전히 nightly 빌드에 포함됩니다. 느리거나 그 밖의 이유로 실험적인 최적화 패스를 사용하려면 -Z mir-opt-level 디버그 플래그를 지정하면 됩니다. 각 수준의 정의는 컴파일러 MCP에서 확인할 수 있습니다. MIR 패스를 개발하면서 최적화 패스를 실행해야 하는지 쿼리하려면 tcx.sess.opts.unstable_opts.mir_opt_level을 사용하여 현재 수준을 확인할 수 있습니다.
MIR 디버깅
-Z dump-mir 플래그를 사용하면 MIR의 텍스트 표현을 덤프할 수 있습니다. -Z dump-mir와 함께 사용하는 다음 선택적 플래그를 쓰면 추가 출력 형식을 사용할 수 있으며, 그 예는 다음과 같습니다:
-Z dump-mir-graphviz- MIR을 제어 흐름 그래프로 나타내는.dot파일을 덤프합니다-Z dump-mir-dataflow- 제어 흐름 그래프의 각 지점에서의 데이터 흐름 상태를 보여 주는.dot파일을 덤프합니다
-Z dump-mir=F는 컴파일의 각 단계에서 각 함수의 MIR을 볼 수 있게 해 주는 편리한 컴파일러 옵션입니다. -Z dump-mir는 필터 F를 받으며, 이 필터로 관심 있는 함수와 패스를 지정할 수 있습니다. 예를 들면 다음과 같습니다.
> rustc -Z dump-mir=foo ...
이렇게 하면 이름에 foo가 포함된 모든 함수의 MIR을 덤프하며, 모든 패스의 전과 후 MIR을 모두 덤프합니다. 해당 파일은 mir_dump 디렉터리에 생성됩니다. 아마 파일이 꽤 많이 생길 것입니다!
> cat > foo.rs
fn main() {
println!("Hello, world!");
}
^D
> rustc -Z dump-mir=main foo.rs
> ls mir_dump/* | wc -l
161
파일 이름은 rustc.main.000-000.CleanEndRegions.after.mir와 같은 형태입니다. 이 이름은 여러 부분으로 구성됩니다:
rustc.main.000-000.CleanEndRegions.after.mir
---- --- --- --------------- ----- either before or after
| | | name of the pass
| | index of dump within the pass (usually 0, but some passes dump intermediate states)
| index of the pass
def-path to the function etc being dumped
더 선별적인 필터를 만들 수도 있습니다. 예를 들어 main & CleanEndRegions는 main과 CleanEndRegions 패스를 모두 참조하는 항목을 선택합니다:
> rustc -Z dump-mir='main & CleanEndRegions' foo.rs
> ls mir_dump
rustc.main.000-000.CleanEndRegions.after.mir rustc.main.000-000.CleanEndRegions.before.mir
필터에 | 부분을 넣어 여러 &-필터 집합을 결합할 수도 있습니다. 예를 들어 main & CleanEndRegions | main & NoLandingPads는 main과 CleanEndRegions 또는 main과 NoLandingPads 중 하나를 선택합니다:
> rustc -Z dump-mir='main & CleanEndRegions | main & NoLandingPads' foo.rs
> ls mir_dump
rustc.main-promoted[0].002-000.NoLandingPads.after.mir
rustc.main-promoted[0].002-000.NoLandingPads.before.mir
rustc.main-promoted[0].002-006.NoLandingPads.after.mir
rustc.main-promoted[0].002-006.NoLandingPads.before.mir
rustc.main-promoted[1].002-000.NoLandingPads.after.mir
rustc.main-promoted[1].002-000.NoLandingPads.before.mir
rustc.main-promoted[1].002-006.NoLandingPads.after.mir
rustc.main-promoted[1].002-006.NoLandingPads.before.mir
rustc.main.000-000.CleanEndRegions.after.mir
rustc.main.000-000.CleanEndRegions.before.mir
rustc.main.002-000.NoLandingPads.after.mir
rustc.main.002-000.NoLandingPads.before.mir
rustc.main.002-006.NoLandingPads.after.mir
rustc.main.002-006.NoLandingPads.before.mir
(여기서 main-promoted[0] 파일은 main 함수 안에 나타난 “승격된 상수(promoted constants)“의 MIR을 가리킵니다.)
-Z unpretty=mir-cfg 플래그를 사용하면 크레이트 전체에 대한 graphviz MIR 제어 흐름 다이어그램을 만들 수 있습니다:
TODO: 그 밖에 추가할 내용이 있습니까?
상수 평가
상수 평가는 컴파일 타임에 값을 계산하는 과정입니다. 특정 항목(상수/정적 변수/배열 길이)의 경우, 이 과정은 해당 항목의 MIR이 빌림 검사와 최적화를 거친 후에 일어납니다. 많은 경우 항목을 상수 평가하려고 시도하면 그 항목의 MIR 계산이 처음으로 촉발됩니다.
대표적인 예는 다음과 같습니다.
static의 초기화 식- 배열 길이
- 스택 또는 힙 공간을 확보하려면 알아야 합니다.
- 열거형 배리언트의 판별값
- 두 배리언트가 같은 판별값을 갖지 않도록 하려면 알아야 합니다.
- 패턴
- 겹치는 패턴을 검사하려면 알아야 합니다.
또한 상수 평가는 복잡한 연산을 컴파일 타임에 미리 계산하고 그 결과만 저장함으로써 런타임의 작업량이나 바이너리 크기를 줄이는 데에도 사용할 수 있습니다.
상수 평가의 모든 사용처는 “타입 시스템에 영향을 주는 것”(배열 길이, 열거형 배리언트 판별값, 상수 제네릭 매개변수)과 런타임에 사용할 표현식을 미리 계산하기 위해서만 수행되는 것 중 하나로 분류할 수 있습니다.
상수 평가는 TyCtxt의 const_eval_* 함수를 호출하여 수행할 수 있습니다. 이 함수들은 const_eval 쿼리의 래퍼입니다.
const_eval_global_id_for_typeck는 상수를 valtree로 평가하므로, 컴파일러가 결과 값을 추가로 검사할 수 있습니다.const_eval_global_id는 상수를 최종 값이 담긴 “불투명한 블롭(opaque blob)“으로 평가합니다. 이는 코드 생성 백엔드와 CTFE 평가 엔진 자체에만 유용합니다.eval_static_initializer는 특별히 정적 변수의 초기값을 계산합니다. 정적 변수는 특별합니다. 다른 모든 함수는 정적 변수를 올바르게 표현하지 못하므로, 정적 변수에 사용하지 못하게 막는 단언문(assertion)을 두고 있습니다.
const_eval_* 함수는 상수가 평가되는 환경(예: 상수가 사용되는 함수)을 나타내는 ParamEnv와 GlobalId를 사용합니다. GlobalId는 상수나 정적 변수를 가리키는 Instance, 또는 함수의 Instance와 그 함수의 Promoted 테이블에 대한 인덱스로 구성됩니다.
상수 평가는 타입 시스템 상수에 대해서는 EvalToValTreeResult를, 그 밖에는 EvalToConstValueResult를 반환하며, 이 결과에는 오류 또는 평가된 상수의 표현이 담기는데, 그 표현은 각각 valtree와 MIR 상수 값입니다.
인터프리터
인터프리터는 MIR을 기계어 코드로 컴파일하지 않고 실행하기 위한 가상 머신입니다. 인터프리터는 보통 tcx.const_eval_* 함수를 통해 호출됩니다. 인터프리터는 컴파일러(컴파일 타임 함수 평가, 즉 CTFE용)와 도구 Miri가 공유합니다. Miri는 같은 가상 머신을 사용하여 (unsafe) Rust 코드의 정의되지 않은 동작(Undefined Behavior)을 탐지합니다.
다음과 같은 상수로 시작한다고 가정해 봅시다.
#![allow(unused)]
fn main() {
const FOO: usize = 1 << 12;
}
rustc는 상수가 사용되거나 메타데이터에 배치되기 전까지는 실제로 아무것도 호출하지 않습니다.
다음과 같은 사용 지점이 있다고 합시다.
type Foo = [u8; FOO - 42];
컴파일러는 해당 타입을 사용하는 항목(지역 변수, 상수, 함수 인자 등)을 생성하기 전에 배열의 길이를 알아내야 합니다.
(이 경우에는 비어 있는) 매개변수 환경을 얻으려면 let param_env = tcx.param_env(length_def_id);를 호출하면 됩니다. 필요한 GlobalId는 다음과 같습니다.
let gid = GlobalId {
promoted: None,
instance: Instance::mono(length_def_id),
};
이제 tcx.const_eval(param_env.and(gid))를 호출하면 배열 길이 표현식의 MIR 생성이 시작됩니다. MIR은 대략 다음과 같은 모습입니다.
Foo::{{constant}}#0: usize = {
let mut _0: usize;
let mut _1: (usize, bool);
bb0: {
_1 = CheckedSub(const FOO, const 42usize);
assert(!move (_1.1: bool), "attempt to subtract with overflow") -> bb1;
}
bb1: {
_0 = move (_1.0: usize);
return;
}
}
평가에 앞서, 평가 결과를 저장하기 위한 가상 메모리 위치(이 경우 사실상 vec![u8; 4] 또는 vec![u8; 8])가 생성됩니다.
평가가 시작될 때 _0과 _1은 Operand::Immediate(Immediate::Scalar(ScalarMaybeUndef::Undef))입니다. 꽤 길고 복잡한 표현입니다. Operand는 인터프리터 메모리 어딘가에 저장된 데이터(Operand::Indirect)를 나타낼 수도 있고, (최적화를 위해) 인라인으로 저장된 즉시 데이터를 나타낼 수도 있습니다. 그리고 Immediate는 (초기화되지 않았을 수도 있는) 단일 스칼라 값(정수 또는 thin 포인터)이거나, 그러한 값 두 개의 쌍일 수 있습니다. 이 경우에는 단일 스칼라 값이 (아직) 초기화되지 않았습니다.
_1의 초기화가 호출되면 FOO 상수의 값이 필요해지고, 이로 인해 tcx.const_eval_*가 다시 호출되지만 여기서는 다루지 않습니다. FOO의 평가가 성공하면 그 값인 4096에서 42를 빼고, 그 결과를 _1에 Operand::Immediate(Immediate::ScalarPair(Scalar::Raw { data: 4054, .. }, Scalar::Raw { data: 0, .. })로 저장합니다. 쌍의 첫 번째 부분은 계산된 값이고, 두 번째 부분은 오버플로가 발생했으면 true가 되는 bool입니다. Scalar::Raw는 이 스칼라 값의 크기(바이트 단위)도 저장하지만, 여기서는 생략합니다.
다음 문장은 앞서 말한 불리언이 0임을 단언합니다. 단언이 실패하면 그 오류 메시지가 컴파일 타임 오류를 보고하는 데 사용됩니다.
단언이 실패하지 않으므로, Operand::Immediate(Immediate::Scalar(Scalar::Raw { data: 4054, .. }))가 평가 전에 할당된 가상 메모리에 저장됩니다. _0은 항상 그 위치를 직접 가리킵니다.
평가가 끝나면 반환 값은 op_to_const에 의해 Operand에서 ConstValue로 변환됩니다. 전자의 표현은 const 평가 도중에 필요한 것에 맞춰져 있는 반면, ConstValue는 const 평가 결과를 소비하는 컴파일러의 나머지 부분에 필요한 형태로 구성되어 있습니다. 이 변환 과정의 일부로, 스칼라 값을 갖는 타입의 경우 결과 Operand가 Indirect이더라도 (일반적인 ConstValue::Indirect 대신) 즉시 값인 ConstValue::Scalar(computed_value)를 반환합니다. 이렇게 하면 usize처럼 단순한 값을 얻기 위해 추가 쿼리를 실행할 필요가 없으므로, 결과를 훨씬 효율적이고 편리하게 사용할 수 있습니다.
이후 같은 상수를 평가할 때는 실제로 인터프리터를 호출하지 않고 캐시된 결과를 그대로 사용합니다.
자료 구조
인터프리터가 외부에 노출하는 자료 구조는 rustc_middle/src/mir/interpret에서 찾을 수 있습니다. 주로 오류 열거형과 ConstValue 및 Scalar 타입입니다. ConstValue는 Scalar(단일 Scalar, 즉 정수 또는 얇은 포인터), Slice(패턴 매칭에 필요한 바이트 슬라이스와 문자열을 표현하기 위한 것), Indirect 중 하나일 수 있으며, Indirect는 그 밖의 모든 경우에 사용되고 가상 할당을 참조합니다. 이러한 할당은 tcx.interpret_interner의 메서드를 통해 접근할 수 있습니다. Scalar는 어떤 Raw 정수이거나 포인터입니다. 이에 대한 자세한 내용은 다음 절을 참고하십시오.
숫자 결과를 기대하는 경우 eval_usize(u64로 표현할 수 없는 값에 대해서는 패닉을 일으킴)를 사용하거나, 가능한 경우 Scalar를 산출하는 Option<u64>를 결과로 내는 try_eval_usize를 사용할 수 있습니다.
메모리
모든 종류의 포인터를 지원하려면 인터프리터에는 포인터가 가리킬 수 있는 “가상 메모리“가 있어야 합니다. 이는 Memory 타입으로 구현되어 있습니다. 가장 단순한 모델에서는 모든 전역 변수, 스택 변수, 그리고 모든 동적 할당이 해당 메모리의 Allocation 하나에 대응합니다. (실제로 모든 MIR 스택 변수마다 할당을 사용하면 매우 비효율적입니다. 그래서 크기가 작으면서 주소를 한 번도 취하지 않는 스택 변수를 위해 Operand::Immediate가 있습니다. 하지만 이는 순전히 최적화일 뿐입니다.)
이러한 Allocation은 기본적으로 이 할당의 각 바이트 값을 저장하는 u8의 시퀀스일 뿐입니다. (여기에 몇 가지 추가 데이터가 더해지며, 아래를 참고하십시오.) 모든 Allocation에는 Memory에서 부여한 전역적으로 고유한 AllocId가 있습니다. 이를 바탕으로 Pointer는 AllocId(할당을 나타냄)와 할당 내 오프셋(포인터가 할당의 어느 바이트를 가리키는지 나타냄)의 쌍으로 구성됩니다. Pointer가 단순한 정수 주소가 아니라는 점이 이상해 보일 수 있지만, 상수 평가 중에는 할당이 실제로 어느 정수 주소에 놓이게 될지 알 수 없다는 점을 기억하십시오. 그래서 AllocId를 기호적 기준 주소로 사용하며, 이 때문에 별도의 오프셋이 필요합니다. (덧붙여 말하자면, 런타임의 포인터 역시 단순한 정수 이상의 것임이 밝혀졌습니다.)
이러한 할당은 참조와 원시 포인터가 가리킬 대상을 갖도록 하기 위해 존재합니다. 모든 것이 할당되는 전역 선형 힙은 존재하지 않으며, 대신 각 할당(지역 변수든, 정적 변수든, (향후의) 힙 할당이든)은 정확히 필요한 크기만큼의 작은 자체 메모리를 받습니다. 따라서 지역 변수 a에 대한 할당을 가리키는 포인터가 있다면, (아무리 unsafe하더라도) 그 포인터를 다른 지역 변수 b를 가리키는 포인터로 바꿀 수 있는 연산은 존재하지 않습니다. a에 대한 포인터 산술은 오직 오프셋만 바꿀 뿐이며, AllocId는 그대로 유지됩니다.
그러나 이는 Pointer를 Allocation에 저장하려 할 때 문제를 일으킵니다. 포인터를 올바른 길이의 u8 시퀀스로 바꿀 수 없기 때문입니다! AllocId와 오프셋을 합치면 포인터가 “보이는” 크기의 두 배가 됩니다. 바로 이것이 Allocation의 relocation 필드가 존재하는 이유입니다. Pointer의 바이트 오프셋은 여러 개의 u8로 저장되고, AllocId는 대역 외(out-of-band)에 따로 저장됩니다. 이 둘은 메모리에서 Pointer를 읽을 때 다시 조립됩니다. Allocation에 필요한 나머지 추가 데이터는 각 바이트가 초기화되었는지 추적하기 위한 undef_mask입니다.
전역 메모리와 특수한 할당
Memory는 평가 중에만 존재하며, 상수의 최종 값이 계산되면 파괴됩니다. 해당 상수에 포인터가 포함되어 있으면, 그 포인터들은 “인턴“되어 TyCtxt의 일부인 전역 “상수 평가 메모리“로 옮겨집니다. 이러한 할당은 나머지 계산 동안 유지되며, (의존하는 크레이트가 사용할 수 있도록) 최종 출력에 직렬화됩니다.
또한 함수 포인터도 지원하기 위해 TyCtxt의 전역 메모리에는 “가상 할당“도 포함될 수 있습니다. 이들은 Allocation 대신 Instance를 담고 있습니다. 이를 통해 Pointer는 일반 데이터나 함수 중 하나를 가리킬 수 있으며, 이는 함수 포인터에서 원시 포인터로의 캐스트를 평가하는 데 필요합니다.
마지막으로, 전역 메모리에서 사용되는 GlobalAlloc 타입에는 특정 const 또는 static 항목을 가리키는 Static 변형도 있습니다. 이는 순환 정적 변수를 지원하는 데 필요합니다. 이 경우 값의 바이트를 아직 알지 못해 Allocation을 가질 수 없는 static에 대한 Pointer가 필요합니다.
포인터 값 대 포인터 타입
인터프리터에서 흔히 혼란을 일으키는 원인 중 하나는 포인터 값이라는 것과 포인터 타입을 가진다는 것이 완전히 독립적인 속성이라는 점입니다. “포인터 값“이란 Pointer를 담고 있어 인터프리터의 가상 메모리 어딘가를 가리키는 Scalar::Ptr를 말합니다. 이는 단지 어떤 구체적인 정수일 뿐인 Scalar::Raw와 대조됩니다.
그러나 *const T나 &T와 같은 포인터 또는 참조 타입의 변수가 반드시 포인터 값을 가질 필요는 없습니다. 정수를 포인터로 캐스팅하거나 트랜스뮤트하여 얻을 수도 있기 때문입니다. 마찬가지로, 실제 할당을 가리키는 참조를 정수로 캐스팅하거나 트랜스뮤트하면 정수 타입(usize)의 포인터 값(Scalar::Ptr)을 얻게 됩니다. 포인터 값에 대해서는 나눗셈과 같은 정수 연산을 의미 있게 수행할 수 없으므로 이는 문제가 됩니다.
해석
상수 평가의 주요 진입점은 tcx.const_eval_* 함수들이지만, rustc_const_eval/src/const_eval에는 ConstValue(Indirect이든 아니든)의 필드에 접근할 수 있게 해 주는 추가 함수들이 있습니다. 컴파일 대상(현재는 LLVM뿐)으로 변환하는 경우를 제외하면 Allocation에 직접 접근해야 할 일은 결코 없어야 합니다.
인터프리터는 현재 평가 중인 상수를 위한 가상 스택 프레임을 생성하는 것으로 시작합니다. 이 가이드를 작성하는 시점에서 상수는 지역(이름 있는) 변수를 허용하지 않는다는 점을 제외하면, 상수와 인자가 없는 함수 사이에는 본질적으로 차이가 없습니다.
스택 프레임은 rustc_const_eval/src/interpret/eval_context.rs의 Frame 타입으로 정의되며, 모든 지역 변수의 메모리(평가 시작 시에는 None)를 담고 있습니다. 각 프레임은 루트 상수의 평가 또는 이후의 const fn 호출에 대한 평가 중 하나를 가리킵니다. 다른 상수를 평가할 때는 단순히 tcx.const_eval_*를 호출하며, 이는 완전히 새롭고 독립적인 스택 프레임을 생성합니다.
프레임은 단지 Vec<Frame>일 뿐이므로, unsafe 코드로 아무리 끔찍한 편법을 쓰더라도 Frame의 메모리를 실제로 참조할 방법은 없습니다. 참조할 수 있는 유일한 메모리는 Allocation들입니다.
이제 인터프리터는 오류를 반환하거나 더 이상 실행할 문장이 없을 때까지 step 메서드(rustc_const_eval/src/interpret/step.rs에 있음)를 호출합니다. 이제 각 문장은 지역 변수 또는 지역 변수가 참조하는 가상 메모리를 초기화하거나 수정합니다. 이 과정에서 다른 상수나 정적 변수를 평가해야 할 수도 있으며, 이는 단순히 tcx.const_eval_*를 재귀적으로 호출합니다.
단형화
아시다시피 Rust는 제네릭 타입을 폭넓게 지원하는 매우 표현력 있는 타입 시스템을 갖추고 있습니다. 하지만 당연히 어셈블리는 제네릭이 아니므로, 코드를 실행하기 전에 모든 제네릭의 구체 타입을 알아내야 합니다.
언어마다 이 문제를 다루는 방식이 다릅니다. 예를 들어 Java와 같은 일부 언어에서는 런타임이 되기 전까지 값의 가장 정확한 타입을 알지 못할 수도 있습니다. Java의 경우에는 (거의) 모든 변수가 어차피 참조 값(즉, 힙에 할당된 객체를 가리키는 포인터)이므로 이것이 문제가 되지 않습니다. 이러한 유연성은 성능을 희생한 대가입니다. 객체에 접근할 때마다 포인터를 역참조해야 하기 때문입니다.
Rust는 다른 접근 방식을 취합니다. Rust는 모든 제네릭 타입을 단형화합니다. 이는 컴파일러가 필요한 구체 타입마다 제네릭 함수 코드의 별도 사본을 찍어 낸다는 뜻입니다. 예를 들어 제 코드에서 Vec<u64>와 Vec<String>을 사용한다면, 생성된 바이너리에는 Vec에 대해 생성된 코드의 사본이 두 개 들어갑니다. 하나는 Vec<u64>용이고 다른 하나는 Vec<String>용입니다. 그 결과 프로그램은 빨라지지만, 컴파일 시간(그 모든 사본을 만드는 데 시간이 걸릴 수 있습니다)과 바이너리 크기(그 모든 사본이 많은 공간을 차지할 수 있습니다)라는 대가를 치르게 됩니다.
단형화는 Rust 컴파일러 백엔드의 첫 번째 단계입니다.
수집
먼저 프로그램에 있는 모든 제네릭 요소에 대해 어떤 구체 타입이 필요한지 알아내야 합니다. 이를 수집이라고 하며, 이 작업을 수행하는 코드를 단형화 수집기라고 합니다.
다음 예제를 보십시오.
fn banana() {
peach::<u64>();
}
fn main() {
banana();
}
단형화 수집기는 [main, banana, peach::<u64>]라는 목록을 제공합니다. 이들은 기계어 코드가 생성될 함수입니다. 수집기는 정적 항목(static) 같은 것들도 이 목록에 추가합니다.
자세한 내용은 수집기 rustdocs를 참고하십시오.
단형화 수집기는 MIR 로워링과 코드 생성 직전에 실행됩니다. rustc_codegen_ssa::base::codegen_crate는 collect_and_partition_mono_items 쿼리를 호출하며, 이 쿼리는 단형화 수집을 수행한 뒤 그 결과를 코드 생성 단위로 분할합니다.
코드 생성 단위(CGU) 분할
증분 빌드 시간을 개선하기 위해 CGU 분할기는 소스 수준 모듈마다 CGU를 두 개씩 만듭니다. 하나는 “안정적인” 코드, 즉 제네릭이 아닌 코드를 위한 것이고, 다른 하나는 더 자주 바뀌는 코드, 즉 단형화/특수화된 인스턴스를 위한 것입니다.
의존성의 경우, 크레이트 B가 크레이트 A에 의존하는 크레이트 A와 크레이트 B를 생각해 보십시오. 다음 표는 크레이트 A에 있는 함수가 크레이트 B의 모듈 하나 이상에서 사용될 수 있는 여러 시나리오를 보여 줍니다.
| 크레이트 A 함수 | 동작 |
|---|---|
| 비제네릭 함수 | 크레이트 A 함수가 크레이트 B의 어떤 코드 생성 단위에도 나타나지 않습니다 |
제네릭이 아닌 #[inline] 함수 | 크레이트 A 함수가 크레이트 B의 단일 CGU 안에 나타나며, 인라이닝 이후 단계가 지나도 계속 존재합니다 |
| 제네릭 함수 | 인라이닝 여부와 관계없이 크레이트 A의 모든 단형화된(특수화된) 함수 는 크레이트 B를 위한 단일 코드 생성 단위 안에 나타납니다. 이 코드 생성 단위는 인라이닝 이후 단계가 지난 뒤에도 존재합니다. |
제네릭 #[inline] 함수 | - 위와 같음 - |
파티셔너에 대한 자세한 내용은 모듈 수준 문서를 읽어 보십시오.
MIR을 코드 생성 IR로 로워링하기
이제 수집기로부터 생성할 심볼 목록을 얻었으므로, 일종의 코드 생성 IR을 생성해야 합니다. 이 장에서는 rustc가 보통 사용하는 LLVM IR을 가정합니다. 실제 단형화(monomorphization)는 번역을 진행하는 도중에 그때그때 수행됩니다.
백엔드는 rustc_codegen_ssa::base::codegen_crate에 의해 시작된다는 점을 떠올려 보십시오. 결국 이는 rustc_codegen_ssa::mir::codegen_mir에 도달하며, 이 함수가 MIR에서 LLVM IR로의 로워링을 수행합니다.
코드는 특정 MIR 기본 요소를 처리하는 모듈들로 나뉘어 있습니다:
rustc_codegen_ssa::mir::block은 블록과 그 종결자(terminator)의 번역을 담당합니다. 이 모듈이 하는 일 중 가장 복잡하면서도 가장 흥미로운 것은 필요한 언와인딩 처리 IR을 포함하여 함수 호출에 대한 코드를 생성하는 것입니다.rustc_codegen_ssa::mir::statement는 MIR 문장을 번역합니다.rustc_codegen_ssa::mir::operand는 MIR 피연산자를 번역합니다.rustc_codegen_ssa::mir::place는 MIR 플레이스(place) 참조를 번역합니다.rustc_codegen_ssa::mir::rvalue는 MIR r-값을 번역합니다.
함수가 번역되기 전에, 더 단순하고 효율적인 LLVM IR을 생성하는 데 도움이 되도록 여러 가지 단순하고 기초적인 분석 패스가 실행됩니다. 이러한 분석 패스의 예로는 어떤 변수가 SSA와 유사한지 파악하는 것이 있으며, 이를 통해 해당 변수들에 대해서는 LLVM의 mem2reg에 의존하지 않고 직접 SSA로 번역할 수 있습니다. 이 분석은 rustc_codegen_ssa::mir::analyze에서 찾을 수 있습니다.
일반적으로 하나의 MIR 기본 블록은 하나의 LLVM 기본 블록에 대응하며, 예외는 매우 적습니다: 인트린직(intrinsic)이나 함수 호출, 그리고 assert처럼 덜 기본적인 MIR 문장은 여러 개의 기본 블록을 만들어 낼 수 있습니다. 이는 코드 생성 중 이식 불가능한 LLVM 전용 부분으로 넘어가기 위한 완벽한 도입부입니다. 인트린직 생성은 중간에 관여하는 추상화 계층이 매우 적어 이해하기가 꽤 쉬우며, rustc_codegen_llvm::intrinsic에서 찾을 수 있습니다.
그 밖의 모든 것은 빌더 인터페이스를 사용합니다. 이것이 앞서 설명한 rustc_codegen_ssa::mir::* 모듈에서 호출되는 코드입니다.
TODO: 상수가 어떻게 생성되는지 설명하기
코드 생성
코드 생성(또는 “codegen”)은 실행 가능한 바이너리를 실제로 생성하는 컴파일러의 부분입니다. 일반적으로 rustc는 코드 생성에 LLVM을 사용하지만, Cranelift와 GCC도 지원합니다. 핵심은 rustc가 코드 생성을 직접 구현하지 않는다는 점입니다. 다만 Rust 소스 코드에서는 백엔드의 많은 부분이 이름에 codegen을 포함하고 있다는 점에 유의할 필요가 있습니다(명확한 경계는 없습니다).
참고: 코드 생성 버그를 디버깅하는 방법에 대한 힌트를 찾고 있다면 디버깅 장의 이 절을 참고하십시오.
LLVM이란 무엇입니까?
LLVM은 “모듈식이며 재사용 가능한 컴파일러 및 툴체인 기술의 모음“입니다. 특히 LLVM 프로젝트에는 플러그인 방식의 컴파일러 백엔드(이 역시 “LLVM“이라고 부릅니다)가 포함되어 있으며, 이 백엔드는 clang C 컴파일러와 우리가 사랑하는 rustc를 비롯한 많은 컴파일러 프로젝트에서 사용됩니다.
LLVM은 LLVM IR 형태로 입력을 받습니다. LLVM IR은 기본적으로 저수준 타입과 주석이 추가된 어셈블리 코드입니다. 이러한 주석은 LLVM IR과 출력되는 기계어 코드에 최적화를 수행하는 데 도움이 됩니다. 이 모든 과정의 최종 결과물은 (마침내) 실행 가능한 무언가(예: ELF 오브젝트, EXE 또는 wasm)입니다.
LLVM을 사용하면 몇 가지 이점이 있습니다:
- 컴파일러 백엔드 전체를 직접 작성할 필요가 없습니다. 이로써 구현 및 유지보수 부담이 줄어듭니다.
- LLVM 프로젝트가 축적해 온 방대한 고급 최적화 모음의 혜택을 누릴 수 있습니다.
- LLVM이 지원하는 모든 플랫폼으로 Rust를 자동으로 컴파일할 수 있습니다. 예를 들어 LLVM이 wasm 지원을 추가하자마자, 짜잔! rustc, clang을 비롯한 여러 언어가 wasm으로 컴파일할 수 있게 되었습니다! (물론 추가로 해야 할 작업이 조금 있었지만, 어쨌든 90%는 이미 된 상태였습니다.)
- 우리와 다른 컴파일러 프로젝트들은 서로 혜택을 주고받습니다. 예를 들어 Spectre 및 Meltdown 보안 취약점이 발견되었을 때 LLVM만 패치하면 되었습니다.
LLVM 실행, 링크 및 메타데이터 생성
모든 함수와 정적 변수 등에 대한 LLVM IR이 만들어지고 나면, LLVM과 그 최적화 패스를 실행하기 시작할 차례입니다. LLVM IR은 “모듈” 단위로 묶입니다. 멀티코어 활용을 돕기 위해 여러 “모듈“을 동시에 코드 생성할 수 있습니다. 이러한 “모듈“을 우리는 코드 생성 단위라고 부릅니다. 이 단위들은 훨씬 이전의 단형화 수집 단계에서 정해졌습니다.
LLVM이 이 모듈들로부터 오브젝트를 생성하면, 이 오브젝트들은 선택적으로 메타데이터 오브젝트와 함께 링커로 전달되고, 그 결과 아카이브나 실행 파일이 생성됩니다.
최적화를 실행하는 것이 반드시 위에서 설명한 코드 생성 단계인 것은 아닙니다. 특정 종류의 LTO에서는 최적화가 대신 링크 시점에 일어날 수도 있습니다. 또한 일부 최적화는 오브젝트가 링커로 전달되기 전에 일어나고, 일부는 링크 도중에 일어날 수도 있습니다.
이 모든 과정은 컴파일의 거의 마지막 단계에서 일어납니다. 이에 대한 코드는 rustc_codegen_ssa::back과 rustc_codegen_llvm::back에서 찾을 수 있습니다. 안타깝게도 이 코드는 LLVM에 의존하는 코드가 제대로 분리되어 있지 않아서, rustc_codegen_ssa에도 LLVM 백엔드에 특화된 코드가 상당히 많이 들어 있습니다.
이 구성 요소들이 작업을 마치면 요청한 출력물에 해당하는 여러 파일이 파일 시스템에 생성됩니다.
LLVM 업데이트하기
Rust는 여러 LLVM 버전을 대상으로 빌드하는 것을 지원합니다.
- 현재 LLVM 개발 브랜치의 트리 최신 상태(tip-of-tree)는 보통 며칠 이내에 지원됩니다. 이러한 수정을 위한 PR에는
llvm-main태그가 붙습니다. - 가장 최근에 릴리스된 메이저 버전은 항상 지원됩니다.
- 그 이전의 한두 개 메이저 버전은 빌드에 성공하고 대부분의 테스트를 통과할 것으로 기대된다는 의미에서 보통 지원됩니다. 하지만 잘못된 컴파일(miscompilation)에 대한 수정은 과거 LLVM 버전으로 백포트되지 않는 경우가 많으므로, 오래된 버전의 LLVM과 함께 rustc를 사용하면 건전성(soundness) 버그의 위험이 커집니다. 최신 버전의 LLVM을 사용할 것을 강력히 권장합니다.
기본적으로 Rust는 rust-lang/llvm-project 저장소에 있는 자체 포크를 사용합니다. 이 포크는 업스트림 프로젝트의 release/$N.x 브랜치를 기반으로 하며, 여기서 $N은 가장 최근에 릴리스된 메이저 버전이거나 릴리스 후보 단계에 있는 현재 메이저 버전입니다. 이 포크는 절대 main 개발 브랜치를 기반으로 하지 않습니다.
저희 LLVM 포크는 다음만 받아들입니다.
- 이미 업스트림에 반영된 변경 사항의 백포트.
- 저희 CI 환경에 영향을 주는 빌드 문제에 대한 우회책.
SGX 활성화를 위해 예외적으로 유지되어 온 패치 하나를 제외하면, 먼저 업스트림에 반영되지 않은 기능적 패치는 받아들이지 않습니다.
LLVM 업데이트에는 절차가 서로 다른 세 가지 유형이 있습니다.
- 현재 LLVM 메이저 버전이 지원되는 동안의 백포트.
- 현재 LLVM 메이저 버전이 더 이상 지원되지 않는 동안의 백포트(또는 변경 사항이 업스트림 백포트 대상이 아닌 경우).
- 새 LLVM 메이저 버전으로의 업데이트.
백포트(업스트림 지원 중)
현재 LLVM 메이저 버전이 업스트림에서 지원되는 동안에는 수정 사항을 먼저 업스트림에 백포트한 다음, 릴리스 브랜치를 Rust 포크로 다시 병합해야 합니다.
- 버그 수정이 업스트림 LLVM에 반영되어 있는지 확인하십시오.
- 아직 반영되지 않았다면 업스트림 릴리스 브랜치로의 백포트를 요청하십시오. LLVM 커밋 권한이 있다면 백포트 절차를 따르십시오. 그렇지 않다면 백포트를 요청하는 이슈를 여십시오. 백포트가 승인되고 병합되면 다음 단계로 진행하십시오.
- rustc가 현재 사용하고 있는 브랜치를 확인하십시오.
src/llvm-project서브모듈은 항상 rust-lang/llvm-project 저장소의 한 브랜치에 고정되어 있습니다. - rust-lang/llvm-project 저장소를 포크하십시오.
- 적절한 브랜치(보통
rustc/a.b-yyyy-mm-dd형식의 이름)를 체크아웃하십시오. git remote add upstream https://github.com/llvm/llvm-project.git를 사용하여 업스트림 저장소를 리모트로 추가하고,git fetch upstream을 사용하여 가져오십시오.upstream/release/$N.x브랜치를 병합하십시오.- 이 브랜치를 여러분의 포크에 푸시하십시오.
- 이전과 같은 브랜치를 대상으로 rust-lang/llvm-project에 풀 리퀘스트를 보내십시오. PR 설명에 수정하려는 Rust 및/또는 LLVM 이슈를 반드시 언급하십시오.
- PR이 병합될 때까지 기다리십시오.
- 버그 수정을 반영하도록
src/llvm-project서브모듈을 업데이트하는 PR을 rust-lang/rust에 보내십시오. 이 작업은 보통 로컬에서git submodule update --remote src/llvm-project로 수행할 수 있습니다. - PR이 병합될 때까지 기다리십시오.
PR 예시: #59089
백포트(업스트림에서 지원하지 않는 경우)
업스트림 LLVM 릴리스는 GA 릴리스 이후 두세 달 동안만 지원됩니다. 업스트림에서 더 이상 백포트를 받지 않으면, 변경 사항을 우리 포크에 직접 체리픽해야 합니다.
- 버그 수정이 업스트림 LLVM에 들어가 있는지 확인하십시오.
- rustc가 현재 사용하는 브랜치를 확인하십시오.
src/llvm-project서브모듈은 항상 rust-lang/llvm-project 저장소의 한 브랜치에 고정되어 있습니다. - rust-lang/llvm-project 저장소를 포크하십시오.
- 적절한 브랜치(보통
rustc/a.b-yyyy-mm-dd라는 이름)를 체크아웃하십시오. git remote add upstream https://github.com/llvm/llvm-project.git를 사용해 업스트림 저장소를 리모트로 추가하고,git fetch upstream으로 페치하십시오.git cherry-pick -x를 사용해 관련 커밋을 체리픽하십시오.- 이 브랜치를 여러분의 포크에 푸시하십시오.
- 이전과 같은 브랜치를 대상으로 rust-lang/llvm-project에 풀 리퀘스트를 보내십시오. PR 설명에 수정하려는 Rust 및/또는 LLVM 이슈를 반드시 언급하십시오.
- PR이 병합될 때까지 기다리십시오.
- 버그 수정을 반영하도록
src/llvm-project서브모듈을 업데이트하는 PR을 rust-lang/rust에 보내십시오. 이 작업은 보통 로컬에서git submodule update --remote src/llvm-project로 수행할 수 있습니다. - PR이 병합될 때까지 기다리십시오.
PR 예시: #59089
새 LLVM 릴리스로 업데이트하기
버그 수정과 달리, 새 LLVM 릴리스로 업데이트하려면 보통 훨씬 더 많은 작업이 필요합니다. 이 경우에는 커밋을 이전 버전으로 체리픽하는 것이 현실적이지 않으므로 전체 업데이트를 해야 합니다. 여기서 할 일이 많으니 각 단계를 하나씩 자세히 살펴봅시다.
-
LLVM이 최신 릴리스 버전의 브랜치를 생성했다고 발표합니다. 이는 llvm/llvm-project 저장소에 보통
release/$N.x라는 이름의 브랜치로 나타나며, 여기서$N은 릴리스되는 LLVM의 버전입니다. -
이
release/$N.x브랜치를 기반으로 rust-lang/llvm-project 저장소에 새 브랜치를 만들고, 이름을rustc/a.b-yyyy-mm-dd로 지정하십시오. 여기서a.b는 브랜치를 만드는 시점에 트리 내에 있는 LLVM의 현재 버전 번호이고, 나머지 부분은 현재 날짜입니다. -
llvm-project 저장소에 Rust 전용 패치를 적용하십시오. 모든 기능과 버그 수정은 업스트림에 반영되어 있지만, 업스트림에 반영하기에는 적절하지 않은 특이한 빌드 관련 패치가 종종 있습니다. 이러한 패치는 일반적으로 rustc가 현재 사용하고 있는 rust-lang/llvm-project 브랜치의 가장 최근 패치들입니다.
-
rust저장소에서 새 LLVM을 빌드하십시오. 이를 위해서는src/llvm-project저장소를 여러분이 만든 브랜치와 리비전으로 업데이트해야 합니다. 또한 일반적으로.gitmodules를 LLVM 서브모듈의 새 브랜치 이름으로 업데이트하는 것이 좋습니다. 서브모듈 업데이트가 되돌려지지 않도록src/llvm-project에 대한 변경 사항을 반드시 커밋하십시오. 실행해야 하는 몇 가지 명령은 다음과 같습니다../x build src/llvm-project- LLVM이 여전히 빌드되는지 테스트합니다./x build- rustc의 나머지 부분을 빌드합니다
업데이트된 LLVM 바인딩으로 컴파일되도록
llvm-wrapper/*.cpp를 업데이트해야 할 가능성이 높습니다. 이전 LLVM 버전에서도 바인딩이 계속 컴파일되도록#ifdef등을 사용해야 한다는 점에 유의하십시오.profile = "compiler"및./x setup이 설정하는 기타 기본값은 LLVM을 소스에서 빌드하는 대신 CI에서 다운로드한다는 점에 유의하십시오. 여러분의 변경 사항이 실제로 사용되도록 하려면 이 기능을 일시적으로 비활성화해야 합니다. 이는bootstrap.toml에 다음 설정을 추가하여 수행합니다.llvm.download-ci-llvm = false -
다른 플랫폼에서 회귀가 발생하는지 테스트하십시오. LLVM에는 tier 1이 아닌 아키텍처에 대한 버그가 적어도 하나는 있는 경우가 많으므로, bors에 보내기 전에 테스트를 좀 더 해 두는 것이 좋습니다! 하지만 리소스가 부족하다면 지금 PR을 그대로 bors에 보내도 되며, 어차피 테스트가 진행됩니다.
이상적으로는 LLVM을 빌드하여 몇 가지 플랫폼에서 테스트하십시오.
- Linux
- macOS
- Windows
그런 다음, CI에서도 실행하는 몇 가지 docker 컨테이너를 실행하십시오.
./src/ci/docker/run.sh wasm32./src/ci/docker/run.sh arm-android./src/ci/docker/run.sh dist-various-1./src/ci/docker/run.sh dist-various-2./src/ci/docker/run.sh armhf-gnu
-
rust-lang/rust에 보낼 PR을 준비하십시오.rust-lang/llvm-project의 메인테이너와 협력하여 여러분의 커밋을 해당 저장소의 브랜치에 반영한 다음,rust-lang/rust에 PR을 보내면 됩니다. 최소한src/llvm-project를 변경하게 되며,llvm-wrapper도 변경하게 될 가능성이 높습니다.선행 사례로 참고할 만한 이전 LLVM 업데이트는 다음과 같습니다.
실제로
src/llvm-project를 업데이트하기 전에llvm-wrapper호환성 작업을 먼저 PR로 반영하는 것이 가장 쉬운 경우도 있다는 점에 유의하십시오. 이렇게 하면 여러분이 LLVM 문제를 해결하는 동안, 새 LLVM을 시험해 보려는 다른 사람들도 여러분이 C++ 바인딩을 업데이트한 작업의 혜택을 받을 수 있습니다. -
이후 몇 달 동안 LLVM은
release/a.b브랜치에 계속해서 커밋을 푸시합니다. 우리는 이러한 버그 수정도 함께 반영하고 싶은 경우가 많습니다. 이를 위한 병합 절차는git merge자체를 사용하여 LLVM의release/a.b브랜치를 2단계에서 만든 브랜치에 병합하는 것입니다. 이 작업은 LLVM의 릴리스 브랜치가 안정화되는 동안 필요할 때마다 보통 여러 번 수행됩니다. -
그런 다음 LLVM이
a.b버전의 릴리스를 발표합니다. -
LLVM이 공식 릴리스된 후에는 rust-lang/llvm-project 저장소에 새 브랜치를 만드는 절차를 다시 따르며, 이번에는 새 날짜를 사용합니다. 그제야 비로소 Rust가 해당 버전을 사용하도록 업데이트하는 PR이 병합됩니다.
git rebase를 수행하므로rust-lang/llvm-project의 커밋 기록은 훨씬 깔끔해 보일 것이며, 기본 LLVM 릴리스 브랜치 위에 Rust 전용 커밋 몇 개만 쌓인 형태가 됩니다.
주의 사항 및 함정
위의 지침대로 순조롭게 진행되는 것이 이상적이지만, 진행하면서 염두에 두어야 할 몇 가지 주의 사항은 다음과 같습니다.
- LLVM 버그는 찾기 어려우므로 주저하지 말고 도움을 요청하십시오! 여기서는 이분 탐색(bisection)이 확실히 큰 도움이 됩니다(네, LLVM은 빌드하는 데 한참 걸리지만, 그래도 이분 탐색은 여전히 큰 도움이 됩니다). 기여자에게 고성능 하드웨어에 대한 원격 접근을 제공하는 계획인 Dev Desktops를 활용할 수 있다는 점에 유의하십시오.
- 일반적인 질문이 있다면 wg-llvm에서 도움을 받을 수 있습니다.
- GitHub에서 브랜치 생성은 권한이 필요한 작업이므로, 대개 쓰기 권한이 있는 사람에게 브랜치 생성을 부탁해야 할 것입니다.
LLVM 디버깅하기
참고: 코드 생성에 관한 정보를 찾고 있다면 대신 이 장을 참조하십시오.
이 절에서는 코드 생성 과정에서 발생하는 컴파일러 버그를 디버깅하는 방법을 다룹니다(예: 컴파일러가 특정 코드를 생성한 이유나 LLVM에서 크래시가 발생한 이유). LLVM은 별도의 디버깅 문서가 필요할 만큼 큰 프로젝트이지만, 다음은 rustc 맥락에서 중요한 몇 가지 팁입니다.
예제 최소화하기
일반적으로 컴파일러는 코드를 분석하면서 많은 정보를 생성합니다. 따라서 보통은 최소한의 예제를 찾는 것이 유용한 첫 단계입니다. 이를 위한 한 가지 방법은 다음과 같습니다.
-
문제를 재현하는 새 크레이트를 만드십시오(예: 문제가 되는 크레이트를 의존성으로 추가하고 새 크레이트에서 사용합니다).
-
외부 의존성을 제거하여 크레이트를 최소화하십시오. 즉, 관련된 모든 것을 새 크레이트로 옮기십시오.
-
코드를 더 짧게 만들어 문제를 더욱 최소화하십시오(
creduce처럼 이 작업을 도와주는 도구가 있습니다).
위의 2단계와 3단계 방법론에 대한 더 자세한 논의는 pnkfelix가 Rust 프로그램 최소화에 관해 특별히 작성한 훌륭한 블로그 글을 참조하십시오.
LLVM 내부 검사 활성화하기
공식 컴파일러(nightly 포함)는 LLVM 단언이 비활성화되어 있으므로, LLVM 단언 실패가 컴파일러 크래시(ICE가 아닌 “진짜” 크래시)나 그 밖의 이상한 동작으로 나타날 수 있습니다. 이러한 문제를 겪고 있다면, LLVM 단언이 활성화된 컴파일러(“alt” nightly 또는 bootstrap.toml에 llvm.assertions = true를 설정하여 직접 빌드한 컴파일러)를 사용해 보고 무언가 드러나는지 확인하는 것이 좋습니다.
rustc 빌드 과정에서는 LLVM 도구들을 build/host/llvm/bin에 빌드합니다. 이 도구들은 직접 호출할 수 있습니다. 이러한 도구는 다음과 같습니다.
llc: 비트코드(.bc파일)를 실행 가능한 코드로 컴파일합니다. LLVM 백엔드 버그를 재현하는 데 사용할 수 있습니다.opt: LLVM 최적화 패스를 실행하는 비트코드 변환기입니다.bugpoint: 큰 테스트 케이스를 작고 유용한 테스트 케이스로 줄여 줍니다.- 그 밖에도 많은 도구가 있으며, 그중 일부는 아래 본문에서 언급합니다.
기본적으로 Rust 빌드 시스템은 LLVM 소스 코드나 LLVM 빌드 구성 설정의 변경 여부를 확인하지 않습니다. 따라서 rustc에 링크되는 LLVM을 다시 빌드해야 한다면, 먼저 build/host/llvm/에 있을 .llvm-stamp 파일을 삭제하십시오.
기본 rustc 컴파일 파이프라인에는 여러 개의 코드 생성 단위가 있는데, 이는 수동으로 재현하기 어렵고 LLVM이 병렬로 여러 번 호출된다는 뜻입니다. 가능하다면(즉, 그렇게 해도 버그가 사라지지 않는다면) rustc에 -C codegen-units=1을 전달하면 디버깅이 더 쉬워집니다.
원시 LLVM 입력 직접 확보하기
rustc가 LLVM IR을 생성하게 하려면 --emit=llvm-ir 플래그를 전달해야 합니다. cargo로 빌드하는 경우 RUSTFLAGS 환경 변수를 사용하십시오(예: RUSTFLAGS='--emit=llvm-ir'). 이렇게 하면 rustc가 대상 디렉터리에 LLVM IR을 출력합니다.
cargo llvm-ir [options] path는 path에 있는 특정 함수의 LLVM IR을 출력합니다. (cargo install --locked cargo-asm을 실행하면 cargo asm과 cargo llvm-ir이 설치됩니다.) --build-type=debug는 디버그 빌드용 코드를 내보냅니다. 이 밖에도 유용한 옵션들이 있습니다. 또한 LLVM IR에 포함된 디버그 정보는 출력을 상당히 어지럽힐 수 있으므로, RUSTFLAGS="-C debuginfo=0"이 매우 유용합니다.
RUSTFLAGS="-C save-temps"는 컴파일 중 여러 단계에서 LLVM 비트코드를 출력하며, 이는 때때로 유용합니다. 출력된 LLVM 비트코드는 컴파일러 출력 디렉터리의 .bc 파일에 저장되며, 이 디렉터리는 rustc에 --out-dir DIR 인자를 전달하여 설정합니다.
-
rustc자체를 호출할 때 LLVM 백엔드에서 단언 실패나 세그멘테이션 오류가 발생한다면, 이.bc파일들을 각각llc명령에 전달해 보고 같은 실패가 발생하는지 확인하는 것이 좋습니다. (LLVM 개발자들은 최소화된 재현을 위해 Rust 크레이트를 사용하는 버그보다.bc파일로 축소된 버그를 선호하는 경우가 많습니다.) -
사람이 읽을 수 있는 형태의 LLVM 비트코드를 얻으려면
llvm-dis를 사용하여 비트코드(.bc) 파일을.ll파일로 변환하기만 하면 됩니다.llvm-dis는 rustc의 대상 로컬 컴파일 결과물에 포함되어 있을 것입니다.
rustc는 LLVM의 최적화가 없더라도 -O의 활성화 여부에 따라 서로 다른 IR을 내보낸다는 점에 유의하십시오. 따라서 rustc가 내보내는 IR을 다뤄 보고 싶다면 다음과 같이 해야 합니다:
$ rustc +local my-file.rs --emit=llvm-ir -O -C no-prepopulate-passes \
-C codegen-units=1
$ OPT=build/$TRIPLE/llvm/bin/opt
$ $OPT -S -O2 < my-file.ll > my
예를 들어 어떤 IR이 최적화 시점의 단언 실패를 일으키는지 확인하거나 LLVM이 특정 최적화를 언제 수행하는지 확인하기 위해 LLVM 파이프라인 도중의 LLVM IR만 얻고 싶다면, rustc 플래그 -C llvm-args=-print-after-all을 전달하고, 필요하다면 -C llvm-args='-filter-print-funcs=EXACT_FUNCTION_NAME을 추가할 수 있습니다(예: -C llvm-args='-filter-print-funcs=_ZN11collections3str21_$LT$impl$u20$str$GT$\ 7replace17hbe10ea2e7c809b0bE').
이렇게 하면 표준 오류로 많은 내용이 출력되므로, 이를 파일로 파이프하는 것이 좋습니다. 또한 -filter-print-funcs와 -C codegen-units=1을 둘 다 사용하지 않는다면, 여러 코드 생성 단위가 병렬로 실행되기 때문에 출력 내용이 서로 뒤섞여 아무것도 읽을 수 없게 됩니다.
-
앞서 설명한 방법에는 한 가지 주의할 점이 있습니다. LLVM의
-print계열 옵션은 패스가 실행되는 IR 단위(예: 함수 하나)만 출력하며, 참조되는 선언, 전역 변수, 메타데이터 등은 포함하지 않습니다. 따라서 일반적으로-print의 출력을llc에 입력하여 특정 문제를 재현할 수는 없습니다. -
LLVM 내부에서
SafeStackLegacyPass::runOnFunction의 시작 부분에서F.getParent()->dump()를 호출하면 모듈 전체가 덤프되며, 이는 재현을 위한 더 나은 기반이 될 수 있습니다. (다만-C save-temps로 덤프된.bc파일에서도 동일한 덤프를 얻을 수 있을 것입니다.)
특정 함수의 IR만 필요하다면(예를 들어 그 함수가 왜 단언 실패를 일으키는지 또는 왜 올바르게 최적화되지 않는지 확인하고 싶다면), 다음과 같이 llvm-extract를 사용할 수 있습니다.
$ ./build/$TRIPLE/llvm/bin/llvm-extract \
-func='_ZN11collections3str21_$LT$impl$u20$str$GT$7replace17hbe10ea2e7c809b0bE' \
-S \
< unextracted.ll \
> extracted.ll
LLVM 최적화 패스 조사하기
최적화 패스로 인해 잘못된 동작이 나타난다면 -opt-bisect-limit이라는 매우 유용한 LLVM 옵션을 사용할 수 있습니다. 이 옵션은 실행할 가장 높은 패스의 인덱스 값을 나타내는 정수를 받습니다. 실행되는 패스의 인덱스 값은 실행할 때마다 동일하게 유지됩니다. 따라서 이를 결과 프로그램을 기반으로 탐색 공간의 이분 탐색을 자동화하는 소프트웨어와 결합하면, 문제를 일으키는 패스를 빠르게 찾아낼 수 있습니다. -opt-bisect-limit이 지정되면 모든 실행 내역이 각각의 인덱스, 그리고 해당 패스가 실행되었는지 건너뛰었는지를 나타내는 출력과 함께 표준 오류에 표시됩니다. 제한 값을 인덱스 -1로 설정하면(예: RUSTFLAGS="-C llvm-args=-opt-bisect-limit=-1") 모든 패스와 그에 해당하는 인덱스 값이 표시됩니다.
최적화 파이프라인을 다뤄 보고 싶다면, rustc가 내보낸 LLVM IR에 ./build/host/llvm/bin/에 있는 opt 도구를 사용할 수 있습니다.
LLVM 자체의 구현을 조사할 때는 LLVM의 내부 디버그 인프라에 대해 알고 있어야 합니다. 이 기능은 LLVM 디버그 빌드에서 제공되며, bootstrap.toml에서 다음 설정을 변경하여 rustc의 LLVM 빌드에서 활성화할 수 있습니다:
# Indicates whether the LLVM assertions are enabled or not
llvm.assertions = true
# Indicates whether the LLVM build is a Release or Debug build
llvm.optimize = false
간단히 요약하면 다음과 같습니다:
assertions=true를 설정하면 대략적인 수준의 디버그 메시지가 활성화됩니다.- 그에 더해
optimize=false를 설정하면 세밀한 수준의 디버그 메시지가 활성화됩니다.
- 그에 더해
- LLVM의
LLVM_DEBUG(dbgs() << msg)는rustc의debug!(msg)와 비슷합니다. -debug옵션은 모든 메시지를 활성화합니다. 이는rustc에서 환경 변수RUSTC_LOG=debug를 설정하는 것과 비슷합니다.-debug-only=<pass1>,<pass2>변형은 좀 더 선택적입니다. 이는rustc에서 환경 변수RUSTC_LOG=path1,path2를 설정하는 것과 비슷합니다.
도움 받기와 질문하기
궁금한 점이 있다면 rust-lang Zulip, 특히 #t-compiler/wg-llvm 채널을 방문하십시오.
알아 두면 좋은 컴파일러 옵션
-C help 및 -Z help 컴파일러 스위치는 유용하게 쓸 수 있는 다양하고 흥미로운 옵션을 나열합니다. 다음은 LLVM 개발과 관련하여 가장 자주 쓰이는 몇 가지 옵션입니다(일부는 위의 튜토리얼에서도 사용되었습니다).
--emit llvm-ir옵션은 텍스트 형식의 LLVM IR이 담긴<filename>.ll파일을 생성합니다--emit llvm-bc옵션은 바이트코드 형식(<filename>.bc)으로 생성합니다
-C llvm-args=<foo>를 전달하면 llc나 opt 같은 도구가 받는 옵션을 거의 모두 전달할 수 있습니다. 예를 들어-C llvm-args=-print-before-all을 전달하면 모든 LLVM 패스 전에 IR을 출력합니다.-C no-prepopulate-passes는 LLVM 패스 관리자에 패스 목록을 미리 채워 넣지 않도록 합니다. 이를 통해 최적화를 거친 LLVM IR이 아니라 rustc가 생성하는 LLVM IR을 볼 수 있습니다.-C passes=val옵션을 사용하면 추가로 실행할 LLVM 패스 목록을 공백으로 구분하여 지정할 수 있습니다-C save-temps옵션은 컴파일 중에 생성되는 모든 임시 출력 파일을 저장합니다-Z print-llvm-passes옵션은 실행 중인 LLVM 최적화 패스를 출력합니다-Z time-llvm-passes옵션은 각 LLVM 패스의 소요 시간을 측정합니다-Z verify-llvm-ir옵션은 LLVM IR이 올바른지 검증합니다--jobs-backend=1은 서로 다른 컴파일 단위의 병렬 컴파일을 비활성화합니다-Z llvm-time-trace옵션은 LLVM 패스의 세부 정보와 소요 시간이 담긴, Chrome 프로파일러와 호환되는 JSON 파일을 출력합니다.-C llvm-args=-opt-bisect-limit=<index>옵션을 사용하면 LLVM 최적화를 이분 탐색할 수 있습니다.
LLVM 버그 보고하기
LLVM 버그 보고서를 제출할 때는 문제를 보여 주는 최소한의 동작 예제가 필요할 것입니다. 이를 위해서는 Godbolt 컴파일러 탐색기가 매우 유용합니다.
-
문제가 되는 코드의 LLVM IR을 얻었다면(위 내용 참고), Godbolt로 최소한의 동작 예제를 만들 수 있습니다. llvm.godbolt.org에 접속하십시오.
-
프로그래밍 언어로
LLVM-IR을 선택하십시오. -
llc를 사용하여 IR을 그대로 특정 타깃으로 컴파일하십시오.- 유용한 플래그가 몇 가지 있습니다.
-mattr은 타깃 기능을 활성화하고,-march=는 타깃을 선택하며,-mcpu=는 CPU를 선택하는 등의 역할을 합니다. llc -march=help같은 명령은 사용 가능한 모든 아키텍처를 출력합니다. Rust의 아키텍처 이름과 LLVM의 이름이 일치하지 않는 경우가 있으므로 이 명령이 유용합니다.- rustc를 어딘가에서 직접 컴파일했다면, target 디렉터리에
llc,opt등의 바이너리가 있습니다.
- 유용한 플래그가 몇 가지 있습니다.
-
LLVM-IR을 최적화하려면
opt를 사용하여 LLVM 최적화가 IR을 어떻게 변환하는지 확인할 수 있습니다. -
문제를 보여 주는 godbolt 링크를 확보했다면 LLVM 버그를 작성하기는 꽤 쉽습니다. LLVM의 GitHub 이슈 페이지를 방문하기만 하면 됩니다.
LLVM의 버그 수정 사항 이식하기
버그가 LLVM 버그임을 확인하고 나면, 해당 버그가 이미 LLVM에 보고되어 수정되었지만 아직 우리 쪽에는 그 수정 사항이 반영되지 않은 경우를 종종 발견하게 됩니다(또는 LLVM에 충분히 익숙하여 직접 수정할 수도 있습니다).
그런 경우에는 rustc가 수정 사항을 더 쉽게 사용할 수 있도록, 버그 수정을 우리의 자체 LLVM 포크에 직접 이식하는 방법을 택하기도 합니다. 우리의 LLVM 포크는 rust-lang/llvm-project에서 관리됩니다. 그곳에 수정 사항을 반영한 뒤에는 서브모듈 커밋을 변경하는 PR도 반영해야 합니다 – 도움이 필요하면 Zulip에 문의하십시오.
백엔드 독립적인 코드 생성
rustc_codegen_ssa는 모든 백엔드, 즉 LLVM, Cranelift, GCC가 구현할 추상 인터페이스를 제공합니다.
아래는 이 추상 인터페이스를 만든 리팩터링에 관한 배경 정보입니다.
rustc_codegen_llvm의 리팩터링
Denis Merigoux 작성, 2018년 10월 23일
리팩터링 이전의 코드 상태
MIR을 LLVM IR로 컴파일하는 것과 관련된 모든 코드는 rustc_codegen_llvm 크레이트 안에 들어 있었습니다. 가장 중요한 요소들을 나누어 보면 다음과 같습니다.
back폴더(7,800줄)는 LLVM을 통해 여러 오브젝트 파일과 아카이브를 생성하는 메커니즘뿐만 아니라 병렬 코드 생성을 위한 통신 메커니즘도 구현합니다.debuginfo폴더(3,200줄)에는 디버그 정보를 LLVM으로 전달하는 모든 코드가 들어 있습니다.llvm폴더(2,200줄)는 C++ API를 사용하여 LLVM과 통신하는 데 필요한 FFI를 정의합니다.mir폴더(4,300줄)는 MIR에서 LLVM IR로의 실제 로워링을 구현합니다.base.rs파일(1,300줄)에는 몇 가지 헬퍼 함수뿐만 아니라 코드 생성을 시작하고 작업을 분배하는 고수준 코드도 들어 있습니다.builder.rs파일(1,200줄)에는 기본 블록 안에서 개별 LLVM IR 명령어를 생성하는 모든 함수가 들어 있습니다.common.rs(450줄)에는 다양한 헬퍼 함수와 LLVM 정적 값을 생성하는 모든 함수가 들어 있습니다.type_.rs(300줄)는 LLVM IR로의 타입 변환 대부분을 정의합니다.
이 리팩터링의 목표는 이 크레이트 안에서 LLVM에 특화된 코드와 다른 rustc 백엔드에서 재사용할 수 있는 코드를 분리하는 것입니다. 예를 들어, mir 폴더는 거의 전적으로 백엔드에 특화되어 있지만, 크레이트의 다른 부분에 크게 의존합니다. 코드 분리는 코드의 로직에도, 성능에도 영향을 주어서는 안 됩니다.
이러한 이유로, 분리 과정에는 결과 코드가 컴파일되도록 동시에 수행해야 하는 두 가지 변환이 포함됩니다.
- 함수 시그니처와 구조체 정의 안의 모든 LLVM 특화 타입을 제네릭으로 대체합니다.
- LLVM FFI를 호출하는 모든 함수를, 백엔드 독립적인 코드와 백엔드 사이의 인터페이스를 정의할 트레이트 집합 안에 캡슐화합니다.
LLVM 특화 코드는 rustc_codegen_llvm에 남겨 두고, 새로운 트레이트와 백엔드 독립적인 코드는 모두 rustc_codegen_ssa로 옮깁니다(이름은 @eddyb가 제안했습니다).
제네릭 타입과 구조체
@irinagpopa는 rustc_codegen_llvm의 타입들을 제네릭 Value 타입으로 매개변수화하는 작업을 시작했으며, 이 타입은 LLVM에서 참조 &'ll Value로 구현됩니다. 이 작업은 mir 폴더 안팎의 모든 구조체뿐만 아니라 LLVM의 BasicBlock 및 Type 타입으로도 확장되었습니다.
LLVM 코드 생성에서 가장 중요한 두 구조체는 CodegenCx와 Builder입니다. 이들은 여러 라이프타임 매개변수와 Value의 타입으로 매개변수화되어 있습니다.
struct CodegenCx<'ll, 'tcx> {
/* ... */
}
struct Builder<'a, 'll, 'tcx> {
cx: &'a CodegenCx<'ll, 'tcx>,
/* ... */
}
CodegenCx는 여러 함수를 포함할 수 있는 하나의 코드 생성 단위(codegen-unit)를 컴파일하는 데 사용되고, Builder는 하나의 기본 블록을 컴파일하기 위해 생성됩니다.
rustc_codegen_llvm의 코드는 여러 명시적 라이프타임 매개변수를 다루어야 하며, 각각은 다음에 해당합니다.
'tcx는 가장 긴 라이프타임으로, 프로그램의 정보를 담고 있는 원래의TyCtxt에 해당합니다.'a는CodegenCx또는 구조체 안의 다른 객체에 대한 수명이 짧은 참조입니다.'ll은Value나Type같은 LLVM 객체에 대한 참조의 라이프타임입니다.
코드에는 이미 라이프타임 매개변수가 많이 있지만, 코드를 제네릭하게 만드는 과정에서 조작 대상인 LLVM 객체의 특수한 성질(이 객체들은 extern 포인터입니다) 때문에만 빌림 검사기를 통과하던 상황들이 드러났습니다. 예를 들어 analyse.rs의 LocalAnalyser에 라이프타임 매개변수를 하나 더 추가해야 했고, 그 결과 다음과 같이 정의되었습니다:
struct LocalAnalyzer<'mir, 'a, 'tcx> {
/* ... */
}
그러나 가장 중요한 두 구조체인 CodegenCx와 Builder는 백엔드에 독립적인 코드에 정의되어 있지 않습니다. 실제로 이 구조체들의 내용은 백엔드에 크게 특화되어 있으므로, 백엔드의 컨텍스트를 위한 제네릭 필드로 좁은 자리만 허용하기보다는 정의 자체를 백엔드 구현자에게 맡기는 편이 더 합리적입니다.
트레이트와 인터페이스
CodegenCx와 Builder는 백엔드가 정의해야 하므로, 백엔드의 인터페이스를 정의하는 모든 트레이트를 구현하는 구조체가 됩니다. 이 트레이트들은 rustc_codegen_ssa/traits 폴더에 정의되어 있으며, 백엔드에 독립적인 모든 코드는 이 트레이트들로 매개변수화되어 있습니다. 예를 들어 base.rs의 함수 하나가 어떻게 매개변수화되는지 설명해 봅시다:
pub fn codegen_instance<'a, 'tcx, Bx: BuilderMethods<'a, 'tcx>>(
cx: &'a Bx::CodegenCx,
instance: Instance<'tcx>
) {
/* ... */
}
이 시그니처에는 앞서 설명한 두 라이프타임 매개변수와, Builder 구조체가 충족하는 인터페이스에 해당하는 BuilderMethods 트레이트를 충족하는 마스터 타입 Bx가 있습니다. BuilderMethods는 연관 타입 Bx::CodegenCx를 정의하며, 이 타입은 다시 CodegenCx 구조체가 구현하는 CodegenMethods 트레이트를 충족합니다.
트레이트 쪽의 예로, 다음은 traits/builder.rs에 있는 BuilderMethods 정의의 일부입니다:
pub trait BuilderMethods<'a, 'tcx>:
HasCodegen<'tcx>
+ DebugInfoBuilderMethods<'tcx>
+ ArgTypeMethods<'tcx>
+ AbiBuilderMethods<'tcx>
+ IntrinsicCallMethods<'tcx>
+ AsmBuilderMethods<'tcx>
{
fn new_block<'b>(
cx: &'a Self::CodegenCx,
llfn: Self::Function,
name: &'b str
) -> Self;
/* ... */
fn cond_br(
&mut self,
cond: Self::Value,
then_llbb: Self::BasicBlock,
else_llbb: Self::BasicBlock,
);
/* ... */
}
마지막으로, base.rs의 codegen_crate처럼 코드 생성을 주도하는 고수준 함수에는 ExtraBackendMethods 트레이트를 구현하는 마스터 구조체가 사용됩니다. LLVM의 경우 이는 비어 있는 LlvmCodegenBackend입니다. ExtraBackendMethods는 rustc_codegen_ssa/src/traits/backend.rs에 정의된 CodegenBackend를 구현하는 구조체와 같은 구조체가 구현해야 합니다.
트레이트화 과정에서 일부 함수는 지역 구조체의 메서드에서 CodegenCx나 Builder의 메서드로 변환되었고, 그에 따라 self 매개변수가 추가되었습니다. 실제로 LLVM은 API를 통해 호출될 때 접근할 수 있는 정보를 내부에 저장합니다. 이 정보는 이러한 메서드가 호출될 때 함께 전달되는 Rust 자료 구조에는 나타나지 않습니다. 그러나 rustc용 Rust 백엔드를 구현할 때는 이러한 메서드가 CodegenCx의 정보를 필요로 하므로 매개변수가 추가된 것입니다(이 매개변수는 트레이트의 LLVM 구현에서는 사용되지 않습니다).
리팩터링 후 코드의 상태
이 트레이트들은 LLVM의 API와 매우 유사한 API를 제공합니다. LLVM은 작업 방식이 매우 독특하므로 이것이 최선의 해법은 아닙니다. 다른 백엔드를 추가할 때는 더 많은 유연성을 제공하기 위해 트레이트 정의가 변경될 수 있습니다.
그러나 현재 백엔드에 독립적인 코드와 LLVM 전용 코드를 분리한 덕분에 기존 rustc_codegen_llvm의 상당 부분을 재사용할 수 있었습니다. 가장 중요한 요소들에 대해 백엔드 독립적(BA) 코드와 LLVM 코드 사이의 새로운 줄 수 분포는 다음과 같습니다:
back폴더: 3,800 (BA) 대 4,100 (LLVM);mir폴더: 4,400 (BA) 대 0 (LLVM);base.rs: 1,100 (BA) 대 250 (LLVM);builder.rs: 1,400 (BA) 대 0 (LLVM);common.rs: 350 (BA) 대 350 (LLVM);
debuginfo 폴더는 분리 과정에서 거의 손대지 않은 채 남아 있으며 LLVM 전용입니다. 이 폴더의 고수준 기능만 트레이트화되었습니다.
새 traits 폴더에는 트레이트 정의만을 위한 코드가 1500줄 있습니다. 전체적으로 27,000줄 규모였던 기존 rustc_codegen_llvm 코드는 18,500줄 규모의 새 rustc_codegen_llvm과 12,000줄 규모의 rustc_codegen_ssa로 분리되었습니다. 이 리팩터링으로 인해, 그렇지 않았다면 rustc의 여러 백엔드 사이에서 중복되어야 했을 약 10,000줄의 코드를 재사용할 수 있게 되었다고 말할 수 있습니다.
리팩터링된 rustc 백엔드는 테스트 스위트와 성능 벤치마크 어느 쪽에서도 회귀를 일으키지 않았습니다. 이는 이번 리팩터링이 컴파일 타임 매개변수화만 사용했고 트레이트 객체는 사용하지 않았다는 특성과 일치하는 결과입니다.
암묵적 호출자 위치
RFC 2091에서 승인된 이 기능을 사용하면 Option::unwrap, Result::expect, Index::index 같은 함수에서 시작된 패닉이 발생할 때 호출자 위치를 정확하게 보고할 수 있습니다. 이 기능은 함수에 붙이는 #[track_caller] 속성, caller_location 내장 함수(intrinsic), 그리고 안정화하기 쉬운 core::panic::Location::caller 래퍼를 추가합니다.
동기가 되는 예제
다음 예제 프로그램을 살펴보십시오.
fn main() {
let foo: Option<()> = None;
foo.unwrap(); // this should produce a useful panic message!
}
Rust 1.42 이전에는 이 unwrap()과 같은 패닉이 core 내부의 위치를 출력했습니다.
$ rustc +1.41.0 example.rs; example.exe
thread 'main' panicked at 'called `Option::unwrap()` on a `None` value',...core\macros\mod.rs:15:40
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
1.42부터는 훨씬 더 유용한 메시지를 얻을 수 있습니다.
$ rustc +1.42.0 example.rs; example.exe
thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', example.rs:3:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
이러한 오류 메시지는 core::panic::Location::caller를 활용하도록 panic! 내부를 변경한 것과, 호출자 정보를 전파하는 표준 라이브러리의 여러 #[track_caller] 어노테이션을 함께 적용하여 구현됩니다.
호출자 위치 읽기
이전에는 panic!이 file!(), line!(), column!() 매크로를 사용하여 패닉이 발생한 위치를 가리키는 Location을 구성했습니다. 이 매크로들에는 재정의된 위치를 전달할 수 없었으므로, 의도적으로 panic!을 호출하는 함수는 자신의 위치를 제공할 수 없었고, 그 결과 실제 오류의 원인이 가려졌습니다.
이제 panic!()은 내부적으로 core::panic::Location::caller()를 호출하여 자신이 어디에서 확장되었는지 알아냅니다. 이 함수 자체에도 #[track_caller] 어노테이션이 붙어 있으며, rustc가 구현하는 caller_location 컴파일러 내장 함수를 감쌉니다. 이 내장 함수는 const 컨텍스트에서 어떻게 동작하는지를 기준으로 설명하는 것이 가장 쉽습니다.
const에서의 호출자 위치
const 컨텍스트에서 호출자 위치를 반환하는 과정은 크게 두 단계로 이루어집니다. 스택을 거슬러 올라가 올바른 위치를 찾는 단계와 반환할 const 값을 할당하는 단계입니다.
올바른 Location 찾기
const 컨텍스트에서는 내장 함수가 호출된 지점부터 “스택을 거슬러 올라가며”, 해당 속성이 없는 첫 번째 함수 호출을 스택에서 만나면 멈춥니다. 이 탐색은 InterpCx::find_closest_untracked_caller_location()에 구현되어 있습니다.
맨 아래에서 시작하여 InterpCx::stack에 있는 스택 Frame들을 위로 순회하면서 각 Frame의 Instance에 대해 InstanceKind::requires_caller_location을 호출합니다. false를 반환하는 것을 찾으면 멈추고, “최상위” 추적 함수였던 직전 프레임의 span을 반환합니다.
정적 Location 할당하기
Span을 얻고 나면 Location을 위한 정적 메모리를 할당해야 하며, 이 작업은 TyCtxt::const_caller_location() 쿼리가 수행합니다. 이 쿼리는 내부적으로 InterpCx::alloc_caller_location()을 호출하며, 그 결과 고유한 메모리 종류(MemoryKind::CallerLocation)가 만들어집니다. SSA 코드 생성 백엔드도 이와 동일한 값에 대한 코드를 내보낼 수 있으며, 그곳에서도 이 코드를 사용합니다.
Location이 정적 메모리에 할당되면, 내장 함수는 그에 대한 참조를 반환합니다.
#[track_caller] 피호출자를 위한 코드 생성
추적되는 함수와 그 호출자를 위한 효율적인 코드를 생성하려면, 런타임에 거슬러 올라갈 스택이 없는 상태에서도 내장 함수 관점에서 동일한 동작을 제공해야 합니다. 그래서 접근 방식을 뒤집습니다. 내장 함수가 호출될 때 스택을 거슬러 올라가는 대신, 스택이 아래로 자라는 동안 추적되는 함수를 호출할 때마다 추가 인자를 전달합니다. 이 추가 인자는 호출자 위치를 조회하는 곳이라면 어디에서든 반환할 수 있습니다.
추가하는 인자의 타입은 &'static core::panic::Location<'static>입니다. 이 글을 작성하는 시점에서 포인터의 크기는 std::mem::size_of::<core::panic::Location>() == 24의 3분의 1이므로, 불필요한 복사를 피하기 위해 참조를 선택했습니다.
추적되는 함수에 대한 호출을 생성할 때는 위치 인자에 FunctionCx::get_caller_location의 값을 전달합니다.
호출하는 함수가 추적되는 함수라면 get_caller_location은 현재 호출자의 호출자가 채워 넣은 FunctionCx::caller_location의 로컬을 반환합니다. 이러한 경우 내장 함수는 실제로는 호출자에게 인자로 제공된 참조를 “반환“합니다.
호출하는 함수가 추적되지 않는 함수라면 get_caller_location은 현재 Span으로부터 Location 정적 값을 할당하고 그에 대한 참조를 반환합니다.
스택을 아래쪽으로 키워 나가면서 여러 FunctionCx의 caller_location 필드를 통해 단일 &Location 값을 전달함으로써, 맨 아래에서 시작하는 루프와 동일한 동작을 더 효율적으로 구현합니다.
코드 생성 예제
이 변환은 실제로 어떤 모습입니까? 새 기능을 사용하는 다음 예제를 살펴보십시오.
#![feature(track_caller)]
use std::panic::Location;
#[track_caller]
fn print_caller() {
println!("called from {}", Location::caller());
}
fn main() {
print_caller();
}
여기서 print_caller()는 인자를 받지 않는 것처럼 보이지만, 실제로는 다음과 같은 형태로 컴파일합니다:
#![feature(panic_internals)]
use std::panic::Location;
fn print_caller(caller: &Location) {
println!("called from {}", caller);
}
fn main() {
print_caller(&Location::internal_constructor(file!(), line!(), column!()));
}
동적 디스패치
코드 생성 맥락에서는 이 정보를 스택 아래로 전달하기 위해 피호출자의 ABI를 수정해야 하지만, 이 속성은 함수의 타입을 명시적으로 수정하지 않습니다. ABI 변경은 타입 검사에 드러나지 않아야 하며 모든 사용에서 건전성을 유지해야 합니다.
추적되는 함수에 대한 직접 호출은 항상 피호출자의 전체 코드 생성 플래그를 알고 있으므로 적절한 코드를 생성할 수 있습니다. 간접 호출자는 이 정보를 갖고 있지 않으며 이 정보는 호출하는 함수 포인터의 타입에도 인코딩되어 있지 않으므로, 함수에 대한 포인터를 취할 때마다 해당 함수를 감싸는 ReifyShim을 생성합니다. 이 심(shim)은 간접 호출의 실제 위치를 보고할 수 없지만(대신 함수의 정의 위치가 보고됩니다), 잘못된 컴파일을 방지하며, 완전히 안정화된 타입 시그니처를 수정하지 않고 할 수 있는 최선일 것입니다.
참고: 추적되는 함수에 대한 포인터를 취할 때는 항상
ReifyShim을 생성합니다. 여기서의 제약은 코드 생성 컨텍스트에 의해 부과되지만, shim의 MIR을 구성하는 동안에는 호출되는 곳이 const 컨텍스트(shim을 무시해도 안전함)인지 코드 생성 컨텍스트(shim을 무시하면 안전하지 않음)인지 알 수 없습니다. 설령 알 수 있다 하더라도, const 맥락과 코드 생성 맥락의 결과는 일치해야 합니다.
속성
#[track_caller] 속성은 다른 코드 생성 속성과 함께 검사되어 함수가 다음 조건을 만족하는지 확인합니다.
"Rust"ABI를 가짐(예:"C"가 아님)- 클로저가 아님
#[naked]가 아님
사용이 유효하면 CodegenFnAttrsFlags::TRACK_CALLER를 설정합니다. 이 플래그는 InstanceKind::requires_caller_location의 반환 값에 영향을 주며, 이 값은 다시 const 맥락과 코드 생성 맥락 모두에서 올바른 전파를 보장하는 데 사용됩니다.
트레이트
트레이트 메서드 구현에 적용하면 이 속성은 일반 함수에서와 동일하게 동작합니다.
트레이트 메서드 프로토타입에 적용하면 이 속성은 해당 메서드의 모든 구현에 적용됩니다. 기본 트레이트 메서드 구현에 적용하면 이 속성은 해당 구현과 모든 재정의에 적용됩니다.
예시:
#![feature(track_caller)]
macro_rules! assert_tracked {
() => {{
let location = std::panic::Location::caller();
assert_eq!(location.file(), file!());
assert_ne!(location.line(), line!(), "line should be outside this fn");
println!("called at {}", location);
}};
}
trait TrackedFourWays {
/// All implementations inherit `#[track_caller]`.
#[track_caller]
fn blanket_tracked();
/// Implementors can annotate themselves.
fn local_tracked();
/// This implementation is tracked (overrides are too).
#[track_caller]
fn default_tracked() {
assert_tracked!();
}
/// Overrides of this implementation are tracked (it is too).
#[track_caller]
fn default_tracked_to_override() {
assert_tracked!();
}
}
/// This impl uses the default impl for `default_tracked` and provides its own for
/// `default_tracked_to_override`.
impl TrackedFourWays for () {
fn blanket_tracked() {
assert_tracked!();
}
#[track_caller]
fn local_tracked() {
assert_tracked!();
}
fn default_tracked_to_override() {
assert_tracked!();
}
}
fn main() {
<() as TrackedFourWays>::blanket_tracked();
<() as TrackedFourWays>::default_tracked();
<() as TrackedFourWays>::default_tracked_to_override();
<() as TrackedFourWays>::local_tracked();
}
배경/역사
대략적으로 말하면, 이 기능의 목표는 안정성 보장을 깨뜨리거나, 최종 사용자 소스의 수정을 요구하거나, 플랫폼별 디버그 정보에 의존하거나, 사용자 정의 타입이 동일한 오류 보고 이점을 누리지 못하게 하는 일 없이 일반적인 Rust 오류 메시지를 개선하는 것입니다.
이러한 패닉의 출력을 개선하는 것은 적어도 2016년 중반부터 여러 제안의 목표였습니다(자세한 내용은 승인된 RFC의 실행 불가능한 대안을 참고하십시오). RFC 2091이 승인되기까지는 2년이 더 걸렸으며, 이 기능의 설계에 대한 근거의 상당 부분은 앞선 여러 제안을 둘러싼 논의를 통해 발견되었습니다.
원래 RFC의 설계는 당시 대대적인 리팩터링 없이 컴파일러 내부에서 수행할 수 있는 구현으로 범위를 한정했습니다. 그러나 RFC 승인과 실제 구현 작업 사이의 1년 반 동안 수정된 설계가 제안되어 추적 이슈에 작성되었습니다. 이를 구현하는 과정에서 함수 MIR의 인자 개수를 변경하지 않고도 구현할 수 있다는 사실도 발견되었으며, 이는 이후 단계를 단순화하고 트레이트에서의 사용을 가능하게 합니다.
RFC의 구현 전략으로는 트레이트를 쉽게 지원할 수 없었기 때문에, 처음에는 그 의미론이 명시되지 않았습니다. 그 이후로 저자와 리뷰어가 가장 올바르다고 판단한 경로에 따라 구현되었습니다.
디버그 정보
디버그 정보는 컴파일러가 생성하는 정보의 모음으로, 디버거가 실행 중인 프로그램의 상태를 올바르게 해석할 수 있게 해 줍니다. 여기에는 명령어 주소를 소스 파일의 코드 줄에 매핑하는 정보와 메모리의 바이트를 의미 있는 방식으로 읽고 표시할 수 있게 하는 타입 레이아웃 정보 등이 포함됩니다.
디버그 정보는 Rust MIR부터 최종 사용자가 화면에서 디버거 출력을 보는 단계까지 모든 계층을 아우르기 때문에 다소 여러 의미로 쓰이는 용어일 수 있습니다. 간단히 말해, 처음부터 끝까지의 스택은 다음과 같습니다.
- Rustc가 MIR을 검사하고 관련 소스, 심볼, 타입 정보를 LLVM에 전달합니다
- LLVM이 컴파일 중에 이 정보를 대상별 디버그 정보 형식으로 변환합니다
- 디버거가 디버그 정보를 읽고 해석하여 소스 줄을 매핑하고, 디버깅 대상의 메모리에 있는 변수를 찾아 올바른 레이아웃으로 읽을 수 있게 합니다
- 디버거에 내장된 서식과 스타일이 변수에 적용됩니다
- 사용자 정의 스크립트가 실행되어 변수에 서식과 스타일을 추가로 적용합니다
- 디버거 프런트엔드가 사용자에게 변수를 표시하며, 이때 추가 API 계층을 거칠 수도 있습니다(예: Debug Adapter Protocol을 통한 VSCode 확장)
참고: 개발 가이드의 이 하위 절은 필요 이상으로 자세할 수도 있습니다. 이 절은 흩어져 있는 많은 정보를 한곳에 모아, 독자가 디버그 스택 전체를 최대한 확실하게 파악할 수 있도록 하는 것을 목표로 합니다.
시각화 스크립트 작업에만 관심이 있다면 디버거 시각화 및 테스트의 정보로 충분합니다. Rust의 디버그 노드 생성 방식을 변경해야 한다면 rust-codegen(Rust 코드 생성) 문서를 참조하십시오. 나머지 절은 모두 보충 자료이지만, 시각화 도구나 코드 생성이 감수해야 하는 몇몇 절충점을 이해하는 데 중요할 수 있습니다. 또한 어떤 문제를 LLVM이나 디버거 자체에서 해결하는 편이 더 나은 경우를 아는 데에도 도움이 될 수 있습니다.
DWARF
이는 *-gnu 대상의 주요 디버그 정보 형식입니다. 일반적으로 바이너리에 함께 포함되지만, 별도의 파일로 생성할 수도 있습니다. DWARF 표준은 여기에서 볼 수 있습니다.
참고: DWARF 디버그 정보를 검사하려면 gimli를 프로그래밍 방식으로 사용할 수 있습니다. GUI를 선호한다면 필자는 DWEX를 추천합니다
PDB/CodeView
*-msvc 대상의 주요 디버그 정보 형식입니다. PDB는 Microsoft가 만든 독점 컨테이너 형식으로, 안타깝게도 여러 의미로 쓰입니다. Portable PDB는 주로 .Net 애플리케이션에 사용되므로, 여기서는 일반 PDB 파일을 다룹니다. PDB 파일은 컴파일된 바이너리와 분리되어 있으며 .pdb 확장자를 사용합니다.
PDB 파일에는 DWARF의 태그에 해당하는 CodeView 객체가 들어 있습니다. CodeView 객체를 사용하던 디버거인 CodeView는 1985년에 처음 출시되었습니다. 원래는 C 디버깅을 위한 것이었으며, 이후 Visual C++를 지원하도록 확장되었습니다. 최신 아키텍처와 언어를 지원하기 위해 지금도 형식이 조금씩 변경되고 있지만, 이러한 변경 사항 중 상당수는 문서화되어 있지 않거나 거의 사용되지 않습니다.
CodeView 객체를 다룰 때는 이러한 배경을 염두에 두는 것이 중요합니다. 이러한 기원 때문에 이 객체들의 “기능 집합“은 매우 제한적이며, C의 핵심 기능을 중심으로 구성되어 있습니다. 최신 DWARF 표준이 제공하는 여러 편의성이나 기능 상당수를 갖추고 있지 않습니다. 디버그 정보 스택 내에는 CodeView의 단점을 보완하기 위한 우회 방법이 상당수 존재합니다.
독점 포맷이라는 특성 때문에 PDB와 CodeView에 관한 정보를 찾기는 매우 어렵습니다. 상당수 자료는 서로 크게 다른 시기에 작성되었으며, 불완전하거나 다소 모순되는 정보를 담고 있습니다. 따라서 이 페이지는 가능한 한 많은 자료를 모으는 것을 목표로 합니다.
- TIS PE 명세에는 “Microsoft Symbol and Type Information“이라는 제목의 긴 절이 포함되어 있으며, CodeView 포맷의 상당 부분을 자세히 설명합니다. 이 문서는 1993년에 작성되었지만, 정보가 여전히 상세하고 정확하며 유용한 도식도 담고 있습니다.
- LLVM
- Microsoft
- microsoft-pdb - PDB 리더의 C/C++ 구현입니다. 이 구현에는 PDB나 CodeView의 전체 명세가 담겨 있지는 않지만, 다른 PDB 소비자를 작성할 수 있을 만큼의 정보는 담겨 있습니다. 이 글을 작성하는 시점(2025년 11월) 기준으로, 이 저장소는 수년째 보관(archived) 상태입니다.
- pdb-rs - 공개된 다른 정보를 바탕으로 만든 Rust 기반 PDB 리더 겸 라이터입니다. 안정성이나 명세 준수를 보장하지 않습니다. PDB 파일을 덤프할 수 있는
pdbtool도 포함하고 있습니다(cargo install --locked pdbtool). - Debug Interface Access SDK입니다. PDB 포맷을 직접 문서화하지는 않지만, 인터페이스 자체에서 세부 사항을 파악할 수 있습니다.
- PDB-Documentation - PDB와 CodeView에 관해 공개된 정보의 상당 부분을 모아 둔 자료입니다.
디버거
Rust는 세 가지 주요 디버거인 GDB, LLDB, CDB를 지원합니다. 각 디버거에는 저마다의 요구 사항, 제약, 특이한 동작이 있습니다. 안타깝게도 이 때문에 고려해야 할 범위가 넓어집니다.
참고: CDB는 Microsoft가 만든 독점 디버거입니다. 그 기반 엔진은 WinDbg, KD, VSCode용 Microsoft C/C++ 확장, 그리고 Visual Studio 디버거의 일부에도 사용됩니다. 이 문서에서는 일관성을 위해 이를 CDB라고 부릅니다.
GDB와 LLDB는 Rust의 값 레이아웃을 네이티브로 지원하는 기능을 제공하지만, 이것이 반드시 필요한 것은 아닙니다. 현재 Rust는 C++와 매우 유사한 디버그 정보를 출력하므로, Rust를 지원하지 않는 디버거에서도 다소 제한된 환경으로나마 작업할 수 있습니다. 자세한 내용은 이후 절에서 다루며, 여기서는 각 디버거의 기능을 간단히 정리합니다:
| 디버거 | 디버그 정보 형식 | 네이티브 Rust 지원 | 표현식 스타일 | 시각화 스크립트 |
|---|---|---|---|---|
| GDB | DWARF | 완전 지원 | Rust | Python |
| LLDB | DWARF 및 PDB | 부분 지원 | C/C++ | Python |
| CDB | PDB | 없음 | C/C++ | Natvis |
중요: CDB는 Windows에서만 실행된다고 가정할 수 있습니다. GDB나 LLDB를 실행하는 OS에 대해서는 어떠한 가정도 할 수 없습니다.
지원되지 않음
아래는 향후 미칠 수 있는 영향 때문에 특히 주목할 만한, 지원되지 않는 디버거 몇 가지입니다.
- Bugstalker는 Rust 프로그램을 디버깅하기 위해 특별히 Rust로 작성된 x86-64 Linux 디버거입니다. 유망하지만 아직 개발 초기 단계에 있습니다.
- RAD Debugger는 Windows 전용 GUI 디버거입니다. 이 디버거에는 PDB를 변환해 담는 자체 디버그 정보 형식이 있습니다. 이 프로젝트에는 링크 단계에서 이 새로운 디버그 정보 형식을 생성할 수 있는 링커도 포함되어 있습니다.
Rust 코드 생성
디버그 정보 생성의 첫 단계에서 Rust는 프로그램의 MIR을 검사하여 이를 LLVM에 전달해야 합니다. 이 작업은 주로 rustc_codegen_llvm/debuginfo에서 이루어지며, 일부 타입 이름 처리는 rustc_codegen_ssa/debuginfo에 있습니다. Rust는 rustc_llvm에 있는, LLVM 내부를 감싼 얇은 래퍼인 DIBuilder API를 통해 LLVM과 통신합니다.
타입 정보
타입 정보는 일반적으로 타입 이름, 크기, 정렬로 구성되며, 관련이 있는 경우 필드, 제네릭 매개변수, 저장 한정자 등도 포함합니다. 이 작업의 상당 부분은 rustc_codegen_llvm/src/debuginfo/metadata에서 이루어집니다.
목표가 반드시 “타입을 Rust에 나타나는 그대로 정확하게 표현하는 것“은 아니며, 오히려 디버거가 디버깅 중에 데이터를 가장 정확하게 재구성할 수 있는 방식으로 타입을 표현하는 것이라는 점을 명심해야 합니다. 이 구분은 이 계층에서 이루어지는 핵심 작업을 이해하는 데 매우 중요합니다. 여기에서 이루어지는 많은 변경은 다른 방법이 통하지 않을 때 디버거의 한계를 우회하기 위한 것입니다.
특이 사항
Rust가 생성하는 DI 노드는 CDB와 LLDB 모두를 위해 C/C++인 “척“합니다. 이로 인해 다소 직관적이지 않고 관용적이지 않은 디버그 정보가 만들어질 수 있습니다.
포인터와 참조
와이드 포인터/참조/Box는 data_ptr와 length라는 2개의 필드를 가진 구조체로 취급됩니다.
와이드가 아닌 모든 포인터, 참조, Box 포인터는 포인터 노드로 출력되며, mut와 비mut는 구분되지 않습니다. 이를 바로잡으려는 시도가 여러 번 있었지만, 안타깝게도 간단한 해결책은 없습니다. 각 형식의 reference DI 노드를 사용하는 데에는 함정이 있습니다. C++ 참조와 Rust 참조 사이에는 조화시킬 수 없는 의미상의 차이가 있습니다.
cppreference에서 인용합니다:
참조는 객체가 아닙니다. 참조는 반드시 저장 공간을 차지하지는 않습니다. 다만 원하는 의미를 구현하는 데 필요하다면 컴파일러가 저장 공간을 할당할 수 있습니다(예: 참조 타입의 비정적 데이터 멤버는 일반적으로 메모리 주소를 저장하는 데 필요한 만큼 클래스의 크기를 늘립니다).
참조는 객체가 아니므로 참조의 배열, 참조를 가리키는 포인터, 참조에 대한 참조는 존재하지 않습니다
현재 제안된 해결책은 단순히 포인터 노드를 typedef하는 것입니다.
비mut를 나타내기 위해 const 한정자를 사용하면 LLDB의 내부 최적화 때문에 문제가 생길 수 있습니다. 요약하면, LLDB는 코드를 단계별로 실행할 때 변수의 자식 값(예: 구조체 필드, 배열 요소)을 캐시하려고 합니다. 어떤 값을 안전하게 캐시할 수 있는지 판단하는 데 휴리스틱이 사용되며, const는 그 휴리스틱의 일부입니다. 이것이 Rust의 내부 가변성 구조 등과 어떻게 상호 작용할지에 대해서는 아직 연구가 이루어지지 않았습니다.
DWARF 대 PDB
타입 정보의 대부분은 상당히 간단하지만, 주목할 만한 문제 하나는 대상의 디버그 정보 형식입니다. 각 형식은 의미와 한계가 서로 다르므로, 경우에 따라 약간씩 다른 디버그 정보가 필요합니다. 이는 cpp_like_debuginfo 호출로 분기됩니다.
이름 지정
Rust는 타입 이름을 가능한 한 정확하게 전달하려고 하지만, 디버거와 디버그 정보 형식이 항상 이를 따르는 것은 아닙니다.
MSVC 표현식 파서의 제약 때문에 PDB 디버그 정보에서는 이름이 다음과 같이 변환됩니다.
| Rust 이름 | MSVC 이름 |
|---|---|
&str/&mut str | ref$<str$>/ref_mut$<str$> |
&[T]/&mut [T] | ref$<slice$<T> >/ref_mut$<slice$<T> >1 |
[T; N] | array$<T, N> |
RustEnum | enum2$<RustEnum> |
(T1, T2) | tuple$<T1, T2> |
*const T | ptr_const$<T> |
*mut T | ptr_mut$<T> |
usize | size_t2 |
isize | ptrdiff_t2 |
uN | unsigned __intN2 |
iN | __intN2 |
f32 | float2 |
f64 | double2 |
f128 | fp1282 |
제네릭
Rust는 제네릭 타입 정보(ArrayVec<T, N: usize>의 T)는 출력하지만, 제네릭 값 정보(ArrayVec<T, N: usize>의 N)는 출력하지 않습니다.
CodeView에는 제네릭/C++ 템플릿을 위한 리프 노드가 없으므로, PDB 디버그 정보를 생성할 때 모든 제네릭 정보가 사라집니다. 디버거가 타입 이름을 통해 제네릭 인자를 가져올 수 있게 하는 우회 방법이 있지만, 좋게 보아도 취약한 해결책에 불과합니다. 이 결함을 바로잡기 위해 Microsoft와 연락하거나, 사용되지 않는 CodeView 노드 타입 중 하나를 적절한 대체물로 사용하려는 노력이 진행 중입니다.
타입 별칭
Rust는 디버거의 한계를 보완하기 위해 여러 경우에 typedef 노드를 출력하지만, 현재 소스 코드의 타입 별칭에 대해서는 노드를 출력하지 않습니다.
열거형
열거형 DI 노드는 rustc_codegen_llvm/src/debuginfo/metadata/enums에서 생성됩니다.
DWARF
DWARF에는 판별 공용체를 위한 전용 노드인 DW_TAG_variant가 있습니다. 이는 판별값을 포함할 수도 있고 포함하지 않을 수도 있는 DW_TAG_variant_part 노드를 참조하는 컨테이너입니다. 계층 구조는 다음과 같습니다.
DW_TAG_structure_type (top-level type for the coroutine)
DW_TAG_variant_part (variant part)
DW_AT_discr (reference to discriminant DW_TAG_member)
DW_TAG_member (discriminant member)
DW_TAG_variant (variant 1)
DW_TAG_variant (variant 2)
DW_TAG_variant (variant 3)
DW_TAG_structure_type (type of variant 1)
DW_TAG_structure_type (type of variant 2)
DW_TAG_structure_type (type of variant 3)
PDB
PDB에는 전용 노드가 없으므로, 판별 공용체에 해당하는 C 구조를 생성합니다.
union enum2$<RUST_ENUM_NAME> {
enum VariantNames {
First,
Second
};
struct Variant0 {
struct First {
// fields
};
static const enum2$<RUST_ENUM_NAME>::VariantNames NAME;
static const unsigned long DISCR_EXACT;
enum2$<RUST_ENUM_NAME>::Variant0::First value;
};
struct Variant1 {
struct Second {
// fields
};
static enum2$<RUST_ENUM_NAME>::VariantNames NAME;
static unsigned long DISCR_EXACT;
enum2$<RUST_ENUM_NAME>::Variant1::Second value;
};
enum2$<RUST_ENUM_NAME>::Variant0 variant0;
enum2$<RUST_ENUM_NAME>::Variant1 variant1;
unsigned long tag;
}
중요한 점은 LLDB의 한계 때문에 값이 #[repr(u64)]가 아니더라도 생성되는 DISCR_* 값은 항상 u64라는 것입니다. DISCR_* 값과 tag는 타입과 관계없이 uint64_t 값으로 읽히므로, LLDB에서는 이것이 대체로 문제가 되지 않습니다.
소스 정보
TODO
LLVM 코드 생성
Rust가 LLVM DIBuilder 함수를 호출하면, LLVM은 주어진 정보를 형식에 구애받지 않는 “디버그 레코드(debug record)”로 변환합니다. 이러한 레코드는 LLVM-IR에서 살펴볼 수 있습니다.
디버그 레코드 안의 태그는 항상 DWARF 태그로 저장된다는 점에 유의해야 합니다. 타깃이 PDB 디버그 정보를 요구하는 경우, 코드 생성 과정에서 디버그 레코드는 DWARF 태그를 대응하는 CodeView 태그로 변환하는 모듈을 거치게 됩니다.
디버거 내부 구조
디버그 정보를 메모리 내 표현으로 변환하는 것은 디버거의 역할입니다. 디버그 정보를 해석하는 방식과 메모리 내 표현 방식은 모두 정해진 것이 없으며, 프로그램이 실행되는 동안 의미 있는 정보를 재구성할 수만 있다면 어떤 방식이든 무방합니다. 원시 디버그 정보에서 사용 가능한 타입에 이르는 파이프라인은 상당히 복잡할 수 있습니다.
정보가 다룰 수 있는 형식으로 준비되면, 디버거 프런트엔드는 데이터를 해석하고 표시하는 방법, 사용자가 데이터와 상호작용하는 방법, 그리고 확장을 위한 API를 제공해야 합니다.
디버거는 방대한 시스템이므로 여기서 모두 다룰 수는 없습니다. 이 절에서는 Rust 디버깅 경험과 직접 관련된 하위 시스템을 간략히 살펴봅니다.
Microsoft의 디버깅 엔진은 소스가 공개되어 있지 않으므로 여기서는 다루지 않습니다.
LLDB 내부 구조
LLDB의 디버그 정보 처리는 주로 lldb/src/Plugins에 정의된 확장 가능한 인터페이스 집합에 의존합니다. 이 인터페이스들은 서드파티 컴파일러 개발자가 LLDB가 런타임에 로드하는 언어 지원을 추가할 수 있도록 하기 위한 것이지만, 이 글을 작성하는 시점(2025년 11월)에는 공개 API가 아직 확정되지 않았으므로 플러그인은 LLDB 자체에 포함되어 있거나 LLDB의 독립 포크에 존재합니다.
일반적으로 언어 지원은 다음과 같은 플러그인 파이프라인으로 작성됩니다: *ASTParser -> TypeSystem -> ExpressionParser/Language.
다음은 LLDB 플러그인 API의 기존 구현 사례입니다.
- Swift를 지원하는 Apple의 포크
- Rust를 지원하던 CodeLLDB의 이전 포크
- 현재 진행 중인 Rust 지원 재구현
- Rust 표현식 파서 플러그인입니다. 이 플러그인은
TypeSystemAPI가 만들어지기 전에 작성되었습니다. 표현식 파싱은 형식이 자유롭기 때문에, 그 밑에 있는 렉싱, 파싱, 함수 호출 등은 여전히 유용한 통찰을 줄 것입니다.
Rust 지원과 TypeSystemClang
디버그 정보 개요에서 언급했듯이 LLDB는 Rust를 부분적으로 지원합니다. 좀 더 명확히 말하자면, Rust는 C/C++용으로 만들어진 플러그인 파이프라인을 사용하며(Rust 열거형 타입을 위한 몇 가지 헬퍼가 포함되어 있기는 합니다), 이 파이프라인은 clang 컴파일러의 타입 표현에 직접 의존합니다. 이 때문에 LLDB의 출력이 우리가 원하는 것과 일치하지 않을 때 변경할 수 있는 범위가 크게 제한됩니다. 몇 가지 우회 방법이 도움이 될 수는 있지만, 결국 Rust의 요구 사항은 C와 C++의 컴파일 및 디버깅이 올바르게 동작하도록 하는 것에 비해 부차적입니다.
LLDB는 TypeSystemRust를 추가하는 것에 열려 있지만, 이는 매우 방대한 작업입니다. 이 절은 현재 우리가 TypeSystemClang과 상호 작용하는 방식을 문서화할 뿐만 아니라, 향후 TypeSystemRust를 구현하기 위한 간단한 지침 역할도 합니다.
TypeSystem이 대상 언어의 컴파일러와 직접 상호 작용하는 것이 의도된 방식이지만, 필수 요건은 아니라는 점에 유의할 필요가 있습니다. 필요한 모든 지원 타입을 플러그인 구현 내부에서 직접 만들 수도 있습니다.
참고: 소스 코드의 주석을 포함한 LLDB의 문서는 상당히 부족합니다.
TypeSystemClang의 구현을 읽으며 언어 지원이 어떻게 동작하는지 이해하려면clang컴파일러의 내부 구조까지 이해해야 하므로 다소 어렵습니다. 위에 나열된 2개의TypeSystemRust구현은 컴파일러의 타입 표현을 활용하지 않고 “처음부터” 작성되었으므로, 이를 살펴보는 것을 권장합니다. 이 구현들은 언어 지원을 구현하는 데 필요한 최소한의 수준에 비교적 가깝습니다.
DWARF와 PDB 비교
LLDB는 DWARF와 PDB 디버그 정보를 모두 처리할 수 있다는 점에서 독특합니다. 다만 이로 인해 복잡성이 다소 늘어납니다. 상황을 더 복잡하게 만드는 것은, PDB 지원이 Visual Studio와 함께 배포되는 msdia140.dll 라이브러리에 의존하는 dia와, PDB 형식에 관해 공개된 정보를 바탕으로 처음부터 작성된 native로 나뉘어 있다는 점입니다.
참고:
dia는 LLDB 버전 21까지 기본값이었습니다. LLDB 22 릴리스부터는native가 새로운 기본값입니다.dia기반 플러그인은 지원 중단 후 완전히 제거될 예정입니다. 따라서 아래에서는native파싱만 다룹니다. 진행 상황은 이 discourse 스레드와 관련 추적 이슈를 참고하십시오.
native는 LLDB 22에서 추가된plugin.symbol-file.pdb.reader설정이나 환경 변수LLDB_USE_NATIVE_PDB_READER=0/1을 사용하여 전환할 수 있습니다
디버그 노드 파싱
첫 번째 단계는 원시 디버그 노드를 사용할 수 있는 형태로 가공하는 것입니다. 이 작업은 주로 DWARFASTParser 클래스와 PdbAstBuilder 클래스에서 이루어집니다. 이 클래스들에는 각각 SymbolFileDWARF와 SymbolFileNativePDB에서 생성된 디버그 정보가 역직렬화된 형태로 입력됩니다. SymbolFile 구현체들은 기반 디버그 정보를 파서에 전달하기 전에 거의 아무런 변환도 하지 않습니다. PDB와 DWARF 모두 LLVM의 디버그 정보 핸들러를 사용하여 디버그 정보를 읽습니다.
파서는 노드를 LLDB의 목적에 더 편리한 형식으로 변환합니다. clang의 경우 이러한 형식은 clang::QualType, clang::Decl, clang::DeclContext이며, 이는 clang이 C와 C++를 컴파일할 때 내부적으로 사용하는 타입입니다. 다시 말하지만 컴파일러의 타입 표현을 반드시 사용해야 하는 것은 아니지만, 플러그인 시스템은 그러한 가능성을 염두에 두고 만들어졌습니다.
참고:
TypeSystemClang의 구체적인 구현 세부 사항이 중요하지 않은 경우, 위의 타입들을 언어에 구애받지 않는 이름인LangType,Decl,DeclContext로 부르겠습니다.
LangType은 타입을 나타냅니다. 여기에는 타입의 이름, 크기와 정렬, 분류(예: 구조체, 기본 타입, 포인터), 한정자(예: const, volatile), 템플릿 인자, 함수 인자 및 반환 타입 등의 정보가 포함됩니다. RustType의 모습을 보여 주는 예시는 여기에 있습니다.
Decl은 모든 종류의 선언을 나타냅니다. 이는 타입, 변수, 구조체의 정적 필드, static이나 const를 초기화하는 값 등일 수 있습니다.
DeclContext는 대략 스코프를 나타냅니다. DeclContext는 일반적으로 Decl과 다른 DeclContexts를 포함하지만, 그 관계가 그렇게 단순하지는 않습니다. 예를 들어 함수는 Decl(함수 시그니처는 타입이므로)이면서 동시에 DeclContext(함수는 변수 선언, 중첩 함수 선언 등을 포함하므로)일 수 있습니다.
변환 과정은 상당히 장황할 수 있지만, 대개는 간단합니다. 여기서 하는 작업의 상당 부분은 LangType, Decl, DeclContext를 채우는 데 필요한 정확한 정보에 따라 달라집니다.
노드가 변환되면 그 노드를 가리키는 포인터는 타입이 소거(void*)된 뒤 CompilerType, CompilerDecl 또는 CompilerDeclContext로 감싸집니다. 이 래퍼들은 해당 객체를 그것을 소유하는 TypeSystem과 연결합니다. 이 객체들의 메서드는 TypeSystem에 위임되며, TypeSystem은 void*를 적절한 LangType*/Decl*/DeclContext*로 다시 캐스팅하여 내부를 조작합니다. Rust 용어로 표현하면 그 관계는 대략 다음과 같습니다.
struct CompilerType {
inner_type: *mut c_void,
type_system: Arc<dyn TypeSystem>,
}
impl CompilerType {
pub fn get_byte_size(&self) -> usize {
self.type_system.get_byte_size(self.lang_type)
}
}
...
impl TypeSystem for TypeSystemLang {
pub fn get_byte_size(lang_type: *mut c_void) -> usize {
let lang_type = lang_type as *mut LangType;
// Operate on the internals of the LangType to
// determine its size
...
}
}
타입 시스템
TypeSystem 인터페이스에는 세 가지 주요 목적이 있습니다.
- 언어의 타입에 대한 “유일한 권한자” 역할을 합니다. 이를 통해 타입 시스템을 LLDB의 타입 시스템 “풀“에 추가할 수 있습니다. 실행 파일이 로드되면 대상 언어가 결정되고, 해당 언어를 처리할 수 있다고 주장하는
TypeSystem을 찾기 위해 풀에 쿼리합니다. 또한TypeSystem을 사용하여 기반이 되는SymbolFile을 가져오거나, 타입을 검색하거나, 디버그 정보에 존재하지 않을 수 있는 기본 타입(예: 원시 타입,T의 배열,T를 가리키는 포인터)을 합성할 수도 있습니다. LangType,Decl,DeclContext객체의 라이프타임을 관리합니다- 해당 타입들이 표시되는 방식과 상호 작용할 수 있는 방식의 “기본값“을 사용자 정의합니다.
처음 두 함수는 꽤 간단하므로 세 번째 함수에 집중하겠습니다.
시각화 스크립트를 다뤄 본 적이 있다면 TypeSystem 인터페이스의 많은 함수가 익숙해 보일 것입니다. 이 함수들은 이름이 같은 SBType과 SBValue 함수들의 기반이 됩니다. 예를 들어 TypeSystem::GetFormat은 사용자 정의 포매터가 적용되지 않은 경우 해당 타입의 기본 포맷을 반환합니다.
특히 주목할 만한 것은 GetIndexOfChildWithName과 GetNumChildren입니다. 이 함수들의 TypeSystem 버전은 SBValue 버전처럼 값에 대해 동작하는 것이 아니라 타입에 대해 동작합니다. TypeSystem 함수가 반환하는 값은 LLDB의 나머지 부분이 구조체의 어느 부분과 조금이라도 상호작용할 수 있는지를 결정합니다. 어떤 필드가 생략되면 LLDB에게 그 필드는 사실상 더 이상 존재하지 않습니다.
또한 이 함수들은 객체를 다루지 않으므로 검사하거나 해석할 기반 메모리가 없습니다. 본질적으로 이는 이 함수들이 대응하는 SyntheticProvider 함수들과 같은 목적을 갖지 않는다는 뜻입니다. Vec에 요소가 몇 개 있는지, 또는 그 요소들이 어느 주소에 있는지 알아낼 방법이 없습니다. 합 타입의 판별값을 알아내는 것도 불가능합니다.
이상적으로는 TypeSystem이 디버그 정보에 나타난 타입을 가능한 한 변경하지 않고 그대로 노출해야 합니다. 타입을 보기 좋게 만드는 일은 LLDB의 합성(synthetic) 기능과 프런트엔드가 처리할 수 있습니다. 어떤 정보가 쓸모없다면, 애초에 해당 디버그 정보를 출력하지 않도록 Rust 컴파일러를 수정해야 합니다.
표현식 파싱
TypeSystem은 일반적으로 표현식 파싱을 처리할 수 있는 짝을 갖도록 작성됩니다. 이를 위해서는 TypeSystem 인터페이스에서 몇 가지 추가 함수를 구현해야 합니다. 표현식 파싱 코드의 대부분은 lldb/source/Plugins/ExpressionParser에 있어야 합니다.
파서에 관해서는 특별히 언급할 내용이 많지 않습니다. (단순화되었을 수도 있는) Rust 구문을 처리할 수 있는 간단한 인터프리터를 구현해야 합니다. 이 인터프리터는 SBValue의 기반이 되는 객체인 lldb::ValueObject에 대해 동작합니다.
언어
Language 플러그인은 Python 시각화 스크립트에 해당하는 C++ 구현입니다. 이 플러그인들은 합성 자식(synthetic children) 생성과 보기 좋은 출력(pretty-printing)이라는 같은 목적을 위해 SBValue 객체에 대해 동작합니다. LibCxx 타입에 대한 CPlusPlusLanguage의 구현은 시각화를 어떻게 작성해야 하는지 배우기에 훌륭한 자료입니다.
이 플러그인들은 LLDB의 비공개 내부 구현(기반이 되는 TypeSystem 포함)에 접근할 수 있으므로, Language 플러그인으로 작성된 synthetic/summary 프로바이더는 Python으로 작성된 것보다 더 높은 품질의 출력을 제공할 수 있습니다.
디버그 노드 파싱, 타입 시스템, 표현식 파싱은 모두 서로 밀접하게 연결되어 있지만, Language 플러그인은 더 잘 캡슐화되어 있으므로 기존 타입 시스템이 지원하는 모든 언어에 대해 “독립적으로” 작성할 수 있습니다. 진입 장벽이 더 낮기 때문에 RustLanguage 플러그인은 LLDB에서 완전한 언어 지원으로 나아가는 좋은 디딤돌이 될 수 있습니다.
시각화
작업 중
GDB 내부 구조
GDB의 Rust 지원은 gdb/rust-lang.h와 gdb/rust-lang.c에 있습니다. 표현식 파싱 지원은 gdb/rust-exp.h와 gdb/rust-parse.c에서 찾을 수 있습니다.
디버거 시각화 도구
이들은 일반적으로 디버거가 정보를 표시하기 전의 마지막 단계이지만, 그 결과가 IDE의 디버거 API와 같은 디버그 어댑터를 거쳐 전달될 수도 있습니다.
“시각화 도구(Visualizer)“라는 용어는 다소 부적절한 명칭입니다. 진정한 목표는 단순히 출력을 보기 좋게 만드는 것이 아니라, 사용자가 상호작용할 수 있는 최대한 유용한 인터페이스를 제공하는 것입니다. 많은 경우 이는 원래 타입을 Rust 표현에 최대한 가깝게 재구성하는 것을 의미하지만, 항상 그런 것은 아닙니다.
시각화 도구 인터페이스를 사용하면 “합성 자식(synthetic children)“을 생성할 수 있습니다. 합성 자식이란 디버그 정보에는 존재하지 않지만 언어와 타입 자체의 불변 조건으로부터 도출할 수 있는 필드를 말합니다. 간단한 예로, Vec<T>의 *mut u8 힙 포인터, 길이, 용량만 보는 대신 그 요소들과 상호작용할 수 있도록 하는 것을 들 수 있습니다.
rust-lldb, rust-gdb, rust-windbg.cmd
이 지원 스크립트들은 Rust 툴체인과 함께 배포됩니다. 이 스크립트들은 적절한 디버거와 툴체인의 시각화 스크립트를 찾은 다음, 디버기를 실행하거나 디버기에 연결하기 전에 시각화 스크립트를 로드하도록 적절한 인자로 디버거를 실행합니다.
#![debugger_visualizer]
이 속성을 사용하면 Rust 라이브러리 작성자가 자신의 타입에 대한 프리티 프린터(pretty printer)를 라이브러리 자체에 포함할 수 있습니다. 이러한 프리티 프린터는 일반적인 시각화 도구와 같은 형식이지만, 컴파일된 바이너리에 직접 내장됩니다. 이 스크립트들은 디버거가 자동으로 불러오므로 사용자에게 매끄러운 경험을 제공합니다. 이 속성은 현재 GDB 및 natvis 스크립트에 대해 동작합니다.
GDB Python 스크립트는 바이너리의 .debug_gdb_scripts 섹션에 포함됩니다(자세한 정보). Rustc는 이를 rustc_codegen_llvm/src/debuginfo/gdb.rs에서 수행합니다.
Natvis 파일은 /NATVIS 링커 옵션을 사용하여 PDB 디버그 정보에 내장할 수 있으며, 타입에 사용할 시각화 도구를 결정할 때 가장 높은 우선순위를 가집니다. 속성으로 지정된 파일은 CrateInfo::natvis_debugger_visualizers에 수집되며, 이후 rustc_codegen_ssa/src/back/linker.rs에서 링커 인자로 추가됩니다.
LLDB는 현재 지원되지 않지만, 향후 지원을 가능하게 할 수 있는 몇 가지 방법이 있습니다. 공식적으로 의도된 방법은 포매터 바이트코드를 이용하는 것입니다. 이는 GDB와 비슷한 경험을 제공하되, Python 스크립트 전체를 포함할 때 따르는 안전 문제는 없도록 하기 위해 만들어졌습니다. 연산 코드(opcode)는 제한적이지만, Python 시각화 스크립트와 거의 같은 방식으로 SBValue 및 SBType과 함께 동작합니다. 이를 구현하려면 일종의 DSL/미니 컴파일러를 작성해야 합니다.
또는 GDB의 전략을 그대로 따라 할 수도 있습니다. 바이너리에 전용 섹션을 만들고 그 안에 Python 스크립트를 포함하는 것입니다. LLDB가 이를 자동으로 로드하지는 않지만, Python API를 사용하면 디버그 정보의 원시 섹션에 접근할 수 있습니다. 이를 이용하면 전용 섹션에서 Python 스크립트를 추출한 다음, Rust 시각화 스크립트가 시작될 때 이를 로드할 수 있을 것입니다.
성능
시각화 도구 자체를 다루기 전에, 이들이 성능에 민감한 시스템의 일부라는 점에 유의해야 합니다. 잠시 격식을 벗어나는 점을 양해해 주십시오. 디버깅에 상당한 시간을 써야 한다면 저는 짜증이 납니다. 디버거를 기다려야 한다면 저는 화가 치밉니다.
이 시각화 도구에서 소비되는 1밀리초는 곧 사용자가 출력을 보기까지 1밀리초를 더 기다려야 한다는 뜻입니다. 이는 많은 컨테이너 타입이나 큰 컨테이너 타입을 포함하는 큰 스택 프레임에서 특히 고통스러울 수 있습니다. VSCode 같은 디버거 GUI는 스택 프레임 전체를 한 번에 요청하며, 이로 인해 프레임 안의 어떤 변수와도 상호작용할 수 있기까지 수십 초(심지어 수 분)의 지연이 발생할 수 있습니다.
Python 코드를 최적화한다는 생각을 꺼리는 경향이 있지만, 실제로 상당한 영향을 줄 수 있습니다. 코드를 빠르게 유지하도록 도와줄 컴파일러가 없다는 점을 기억하십시오. 간단한 변환조차 대신 수행해 주지 않습니다. Python은 굳이 최적화할 필요가 없다는 사람들의 온갖 잡음 속에서 Python 성능 팁을 찾기는 어려울 수 있으므로, 이 스크립트와 관련하여 염두에 둘 만한 몇 가지 사항을 소개합니다.
- 모든 것이 할당을 수행하며,
int조차 예외가 아닙니다 - 가능하면 튜플을 사용하십시오.
list는 사실상Vec<Box<[Any]>>인 반면, 튜플은Box<[Any]>에 해당합니다. 튜플은 간접 참조 계층이 하나 적고, 여분의 용량을 갖지 않으며, 늘어나거나 줄어들 수 없으므로 많은 경우에 유리할 수 있습니다. 추가적인 이점으로, Python은 크기가 20 이하인 모든 튜플의 기반 할당을 캐시하고 재사용합니다. - 정규식은 느리므로 간단한 문자열 조작으로 충분할 때는 피해야 합니다
- 문자열은 불변이므로 많은 문자열 연산이 암묵적으로 내용을 복사합니다.
- 큰 문자열 리스트를 연결할 때는 일반적으로
"".join(iterable_of_strings)가 가장 빠른 방법입니다. - 문자열을 괄호로 감싸는 것처럼 작고 간단한 문자열 변환에는 일반적으로 f-string이 가장 빠른 방법입니다.
- 함수를 호출하는 행위 자체가 다소 느립니다(함수가 완전히 비어 있더라도 마찬가지입니다). 해당 코드 구간이 매우 자주 실행된다면 함수를 수동으로 인라이닝하는 것을 고려하십시오.
- 지역 변수 접근은 전역 및 내장 함수 접근보다 훨씬 빠릅니다
.연산자를 통한 멤버/메서드 접근도 느리므로, 이 비용을 피하려면 깊게 중첩된 값을 지역 변수에 다시 할당하는 것을 고려하십시오(예:h = a.b.c.d.e.f.g.h).- 상속된 메서드와 필드에 접근하는 것은 기반 클래스의 메서드와 필드에 접근하는 것보다 약 2배 느립니다. 가능하면 상속을 피하십시오.
- 가능한 모든 곳에서
__slots__를 사용하십시오.__slots__는 클래스의 필드가 변경되지 않을 것임을 Python에 알리는 방법이며, 필드 접근 속도를 눈에 띄게 향상시킵니다. 이를 위해서는 필드 이름을 미리 지정하고__init__에서 초기화해야 하지만, 얻는 이점에 비하면 작은 대가입니다. - match 문과 if..elif..else는 어떤 방식으로도 최적화되지 않습니다. 조건은 순서대로 하나씩 검사됩니다. 가능하다면 딕셔너리 디스패치나 값 테이블 같은 대안을 사용하십시오
- 가능하면 지연 계산을 하십시오
- 리스트 컴프리헨션은 일반적으로 루프보다 빠르며, 제너레이터 컴프리헨션은 리스트 컴프리헨션보다 약간 느리지만 메모리를 더 적게 사용합니다. 컴프리헨션은 Rust의
iter.map()에 해당한다고 생각할 수 있습니다. 리스트 컴프리헨션은 사실상 마지막에collect::<Vec<_>>를 호출하는 반면, 제너레이터 컴프리헨션은 그렇지 않습니다.
LLDB - Python 프로바이더
참고: LLDB의 C++<->Python FFI는 LLDB를 컴파일할 때 지정된 Python 버전을 요구합니다. LLDB는 이 버전을 일반적인 Linux 및 macOS 배포판의 최소 버전에 맞추도록 신경 쓰지만, Windows에서는 쉬운 해결책이 없습니다.
_lldb가 존재하지 않는다는 import 오류가 발생한다면 Python 버전 불일치가 원인일 가능성이 높습니다.LLDB는 이 문제에 대한 해결책을 검토하고 있습니다. 최신 소식은 이 토론과 이 GitHub 이슈를 참고하십시오.
NOTE: 2025년 11월 기준으로 LLDB가 지원하는 최소 Python 버전은 3.8이며, 여러 외부 요인에 따라 3.9 또는 3.10으로 올릴 계획입니다. 스크립트는 가급적 지원되는 최소 Python 버전에서 사용할 수 있는 기능만으로 작성해야 합니다. 자세한 내용은 이 토론을 참고하십시오.
참고: LLDB의 Python 패키지 경로는 CLI 명령
lldb -P로 확인할 수 있습니다.
LLDB는 출력을 사용자 정의하는 3가지 메커니즘을 제공합니다.
- 포맷
- 합성 프로바이더
- 요약 프로바이더
포맷
공식 문서는 여기에 있습니다. 요약하자면, 형식(format)을 사용하면 기본 타입의 기본 출력 형식을 설정할 수 있습니다(예: 25u8을 10진수 25, 16진수 0x19, 또는 바이너리 00011001로 출력).
Rust에서는 거의 항상 unsigned char, signed char, char, u8, i8을 (부호 없는) 10진수 형식으로 재정의해야 합니다.
합성 프로바이더
공식 문서는 여기에 있지만, 일부 정보는 모호하거나 오래되었거나 아예 빠져 있습니다.
사용자가 변수와 상호작용하는 거의 모든 경우는 LLDB의 SBValue 객체를 통해 이루어지며, 이 객체는 Python API에서도, LLDB의 플러그인과 CLI 내부에서도 사용됩니다.
합성 프로바이더는 특정 인터페이스에 맞춰 작성된 Python 클래스로, 하나 이상의 Rust 타입과 연결됩니다. 합성 프로바이더는 SBValue 객체를 감싸며, LLDB는 변수를 검사할 때 우리 클래스의 함수를 호출합니다.
감싸진 값은 여전히 SBValue이지만, 예를 들어 SBValue.GetChildAtIndex를 호출하면 내부적으로 SyntheticProvider.get_child_at_index가 호출됩니다. 값에 합성 프로바이더가 있는지는 SBValue.IsSynthetic()로, 어떤 합성 프로바이더인지는 SBValue.GetTypeSynthetic()으로 확인할 수 있습니다. 내부의 합성되지 않은 값과 상호작용하려면 SBValue.GetNonSyntheticValue()를 호출하면 됩니다.
요구되는 인터페이스는 다음과 같습니다.
class SyntheticProvider:
def __init__(self, valobj: SBValue, _lldb_internal): ...
# optional
def update(self) -> bool: ...
# optional
def has_children(self) -> bool: ...
# optional
def num_children(self, max_children: int) -> int: ...
def get_child_index(self, name: str) -> int: ...
def get_child_at_index(self, index: int) -> SBValue: ...
# optional
def get_type_name(self) -> str: ...
# optional
def get_value(self) -> SBValue: ...
아래에서는 각 메서드와 그 특이점, 그리고 일반적인 사용 방법을 설명합니다. 메서드가 SBValue 메서드를 재정의하는 경우 해당 메서드도 함께 표기합니다.
__init__
이 함수는 객체당 한 번 호출되며, 다른 곳에서 접근할 수 있도록 valobj를 Python 클래스에 저장해야 합니다. 그 밖의 작업은 여기서 최소한으로만 수행해야 합니다.
(선택 사항) update
이 함수는 __init__ 이후, LLDB가 변수와 상호 작용하기 전에 호출됩니다. LLDB는 update가 이미 호출되었는지 추적합니다. 이미 호출되었고 변수가 변경되었을 가능성이 없다면(예: 스텝 실행 없이 같은 변수를 두 번째로 검사하는 경우), LLDB는 update 호출을 생략합니다.
이 함수에는 두 가지 목적이 있습니다:
- 마지막으로
update가 실행된 이후 변경되었을 수 있는 모든 정보를 저장/갱신합니다. - 자식 캐시를 비워야 할 만큼 자식에 변경이 있었다면 이를 LLDB에 알립니다.
대표적인 작업으로는 Vec의 힙 포인터, 길이, 용량, 요소 타입을 저장하는 일, 열거형 변수의 배리언트를 판별하는 일, HashMap의 어느 슬롯이 사용 중인지 확인하는 일 등이 있습니다.
이 함수가 반환하는 bool 값은 다소 복잡하므로, 자세한 내용은 아래의 update 캐싱을 참고하십시오. 확실하지 않다면 False/None을 반환하십시오. 2025년 11월 기준으로 True를 반환하는 시각화는 하나도 없습니다. 하지만 디버그 정보 테스트 스위트가 개선되면 달라질 수 있습니다.
update 캐싱
LLDB는 가능한 경우 자식 값을 포함한 값을 캐시하려고 시도합니다. 이 캐시는 사실상 자식 객체의 개수와, 자식 객체가 나타내는 디버기(debugee)의 기반 메모리 주소로 이루어집니다. True를 반환하면 마지막으로 update가 실행된 이후 자식의 개수와 그 자식들의 주소가 변경되지 않았음을 LLDB에 알리는 것이며, 이는 캐시된 자식을 재사용할 수 있다는 뜻입니다.
잘못된 상황에서 True를 반환하면 디버거가 잘못된 정보를 출력하게 됩니다.
False를 반환하면 변경이 있었음을 나타내며, 캐시가 비워지고 자식을 처음부터 다시 가져옵니다. 확실하지 않다면 이쪽이 더 안전한 선택입니다.
중요한 관계는 부모-자식 관계뿐입니다. 손자는 조부모가 아니라 직계 부모의 update 함수에 의존합니다.
자식 캐시를 메모리를 가리키는 포인터로 보는 것이 중요합니다. 예를 들어 슬라이스의 data_ptr 값과 length가 변경되지 않았다면 True를 반환하는 것이 적절합니다. 슬라이스가 가변이어서 그 요소를 덮어쓰더라도(예: slice[0] = 15), 자식 캐시는 포인터로 구성되어 있으므로 해당 메모리 위치의 새 데이터를 반영합니다.
반대로 data_ptr가 변경되었다면 메모리의 새 위치를 가리키고 있다는 뜻이므로, 자식 포인터는 무효이며 캐시를 비워야 합니다. length가 변경되었다면 새로운 자식 개수를 반영하기 위해 캐시를 비워야 합니다. length는 변경되었지만 data_ptr는 변경되지 않았다면, 이전 자식을 SyntheticProvider 자체에 저장해 두고(예: list[SBValue]) 처음부터 생성하는 대신 그것을 내어 줄 수 있으며, SyntheticProvider의 목록에 아직 없는 경우에만 새 자식을 생성하면 됩니다.
더 자세한 설명은 이 논의를 참고하십시오.
참고: 캐싱 동작을 테스트할 때는 스텝 실행 시 변수를 유지하는 LLDB의 휴리스틱에 의존하지 마십시오. 대신 변수를 Python 객체에 저장하고(예:
v = lldb.frame.var("var_name")), 앞으로 스텝 실행한 다음, 저장된 변수를 검사하십시오.
(선택 사항) has_children
SBValue.MightHaveChildren을 재정의합니다
이 함수는 LLDB가 값에 자식이 하나라도 있는지 확인할 때 사용하는 지름길로, 자식이 몇 개인지 알아내는 데 드는, 비용이 클 수 있는 계산(예: 연결 리스트)을 수행하지 않습니다. 대개 return True/return False 또는 return self.valobj.MightHaveChildren()처럼 한 줄로 작성합니다.
(선택 사항) num_children
SBValue.GetNumChildren을 재정의합니다
타입을 출력할 때 LLDB가 접근을 시도해야 하는 자식의 총개수를 반환합니다. 이 수는 합성 자식의 총개수와 일치할 필요가 없습니다.
자식의 개수를 계산하는 비용이 클 수 있는 경우(예: 연결 리스트) max_children 인자를 반환할 수 있습니다. 이 점을 고려할 필요가 없다면 함수 시그니처에서 max_children을 생략해도 됩니다.
또한 필드를 LLDB에서는 의도적으로 “숨기면서도” 사용자는 계속 접근할 수 있게 할 수 있습니다. 예를 들어 vec![1, 2, 3]은 원소만 표시하되, len 값과 capacity 값은 요청하면 접근할 수 있게 하고 싶을 수 있습니다. num_children에서 3을 반환하면 LLDB가 [1, 2, 3]만 표시하도록 제한할 수 있으며, 사용자는 여전히 v.len과 v.capacity에 직접 접근할 수 있습니다. 이를 구현한 예는 예제 프로바이더: Vec<T>를 참고하십시오.
get_child_index
SBValue.GetIndexOfChildWithName을 재정의합니다
SBValue.GetChildMemberWithName에 영향을 줍니다
이름을 받아 해당 자식에 접근할 때 사용할 인덱스를 반환합니다. 이 함수의 반환값은 get_child_at_index에 그대로 전달된다고 가정합니다. num_children과 마찬가지로, 여기서 반환하는 값은 get_child_at_index와 제대로 맞춰져 있기만 하면 임의의 값이어도 됩니다.
특수한 값으로 $$dereference$$가 있습니다. 이 의사 필드를 처리하면, LLDB가 표현식 파서로 역참조할 때(예: *val, val->field) get_child_at_index에서 반환된 SBValue를 그 결과로 사용할 수 있습니다.
get_child_at_index
SBValue.GetChildAtIndex를 재정의합니다
인덱스를 받아 자식 SBValue를 반환합니다. 이 값은 보통 SBValue.CreateValueFromAddress로 생성하며, 드물게는 SBValue.CreateChildAtOffset, SBValue.CreateValueFromExpression, SBValue.CreateValueFromData로 생성하기도 합니다. 이 함수들은 다소 까다로울 수 있으므로 원하는 출력을 얻으려면 이리저리 조정해 보아야 할 수도 있습니다.
경우에 따라서는 SBValue.Clone이 적합합니다. 이 함수는 기존 자식을 그대로 복사하되 이름만 새로 붙인 자식을 만듭니다. 이는 튜플처럼 필드 이름이 __0, __1, … 형식이지만 0, 1, …로 이름 붙기를 원하는 경우에 유용합니다.
생성된 자식을 반환하기 전에 약간 수정할 수도 있습니다. 이는 &str/String처럼 자식을 단순한 10진수 값이 아니라 lldb.eFormatBytesWithASCII로 표시하고 싶은 경우에 유용합니다.
(선택 사항) get_type_name
SBValue.GetDisplayTypeName을 재정의합니다.
타입의 표시 이름을 재정의합니다. 타입 이름이 재정의된 합성 SBValue라도 원래 타입 이름은 SBValue.GetTypeName()과 SBValue.GetType().GetName()으로 여전히 가져올 수 있습니다.
이 기능은 자주 쓰이는 표준 라이브러리 타입의 이름을 줄이거나(예: std::collections::hash::map::HashMap<K, V, std::hash::random::RandomState> -> HashMap<K, V>), MSVC 타입 이름을 정규화할 때(예: ref$<str$> -> &str) 유용합니다.
문자열 조작은 다소 까다로울 수 있습니다. 타입의 제네릭 매개변수에 편리하게 접근할 수 없는 MSVC에서는 특히 그렇습니다.
(선택 사항) get_value
SBValue.GetValue(),SBValue.GetValueAsUnsigned(),SBValue.GetValueAsSigned(),SBValue.GetValueAsAddress()를 재정의합니다.
반환되는 SBValue는 원시 타입이나 포인터여야 하며, 표현식에서 변수의 값으로 취급됩니다.
중요: 반환되는
SBValue는 반드시SyntheticProvider에 저장해야 합니다. 2025년 11월 기준으로SBValue를get_value안에서 얻은 뒤 어디에도 저장하지 않으면, LLDB가 그 값에 접근할 때 Python이 세그폴트를 일으키는 버그가 있습니다.
요약 프로바이더
요약 프로바이더는 다음과 같은 형태의 Python 함수입니다:
def SummaryProvider(valobj: SBValue, _lldb_internal) -> str: ...
반환된 문자열은 가공 없이 그대로 사용자에게 전달됩니다. 반환값이 문자열이 아니면 단순하게 문자열로 변환됩니다(예: return None은 빈 문자열이 아니라 "None"을 출력합니다).
전달된 SBValue가 합성 프로바이더를 가진 타입이라면 valobj.IsSynthetic()은 True를 반환하고, 합성 프로바이더의 해당 함수들이 사용됩니다. 이렇게 동작하지 않기를 원한다면 valobj.GetNonSyntheticValue()로 원래 값을 가져올 수 있습니다. 이는 String 같은 경우에 유용할 수 있습니다. 이런 경우 루프에서 GetChildAtIndex를 하나씩 호출하는 것은 힙 포인터에 접근하여 디버기의 메모리에서 바이트 배열 전체를 직접 읽고 Python의 bytes.decode()를 사용하는 것보다 훨씬 느립니다.
인스턴스 요약
일반적인 SummaryProvider 함수는 불투명한 SBValue를 인자로 받습니다. 해당 타입에 SyntheticProvider가 있으면 그 SBValue에 반영되지만, SyntheticProvider 인스턴스 자체나 그 내부 구현 세부 사항에는 접근할 수 없습니다. 요약을 완성하는 데 이러한 내부 세부 사항이 일부 필요한 경우에는 이 점이 걸림돌이 됩니다. 2025년 11월 기준으로 합성 쪽에서는 합성이 아닌 값을 합성 프로바이더에 그대로 통과시키기만 하는데(synth = SyntheticProvider(valobj.GetNonSyntheticValue(), _dict)), 이는 분명히 최적이 아니므로 아래에 설명한 방법을 사용할 계획입니다.
그 대신 Python 모듈의 상태를 활용하여 인스턴스 요약을 구현할 수 있습니다. 이 기법은 예전 CodeLLDB Rust 시각화 스크립트에서 이미 사용된 바 있습니다.
간단히 말해, 모든 합성 프로바이더의 __init__ 함수는 고유 ID와 self에 대한 약한 참조를 전역 딕셔너리에 저장합니다. 합성 프로바이더 클래스는 get_summary 함수도 구현합니다. 해당 타입의 SummaryProvider는 이 딕셔너리에서 고유 ID를 찾은 다음, 찾아낸 인스턴스의 get_summary를 호출하는 함수입니다.
import weakref
SYNTH_BY_ID = weakref.WeakValueDictionary()
class SyntheticProvider:
valobj: SBValue
# slots requires opting-in to __weakref__
__slots__ = ("valobj", "__weakref__")
def __init__(valobj: SBValue, _dict):
SYNTH_BY_ID[valobj.GetID()] = self
self.valobj = valobj
def get_summary(self) -> str:
...
def InstanceSummaryProvider(valobj: SBValue, _dict) -> str:
# GetNonSyntheticValue should never fail as InstanceSummaryProvider implies an instance of a
# `SyntheticProvider`. No non-synthetic types should ever have this summary assigned to them
# We use GetNonSyntheticValue because the synthetic vaobj has its own unique ID
return SYNTH_BY_ID[valobj.GetNonSyntheticValue().GetID()].get_summary()
예를 들어, 열거형 합성 프로바이더에 이 방법을 사용할 수 있습니다. 요약은 배리언트 이름에 접근해야 하지만, 합성의 타입 이름이나 자식 값을 통해 이를 반영할 편리한 방법이 없습니다. 인스턴스 요약을 구현하면 self.variant.GetTypeName()과 약간의 문자열 조작을 통해 배리언트 이름을 가져올 수 있습니다.
시각화 스크립트 작성하기
중요: GDB 및 CDB와 달리, LLDB는 DWARF와 PDB 중 어느 디버그 정보를 가진 실행 파일이든 디버깅할 수 있습니다. 시각화 도구는 가능한 한 두 형식을 모두 고려하여 작성해야 합니다. 차이점에 대한 개요는 rust-codegen(코드 생성)을 참조하십시오.
스크립트는 CLI 명령 command script import <path-to-script>.py를 통해 LLDB에 주입됩니다. 주입되고 나면 type synthetic add와 type summary add를 사용하여 클래스와 함수를 각각 합성 풀과 요약 풀에 추가할 수 있습니다. 요약과 합성 프로바이더는 “카테고리“와 연결될 수 있으며, 카테고리는 일반적으로 해당 프로바이더가 대상으로 하는 언어의 이름을 따서 짓습니다. 여기서 사용할 카테고리의 이름은 Rust입니다.
팁: 모든 LLDB 명령 앞에
help를 붙이면(예:help type synthetic add) 간단한 설명, 인자 목록, 예제를 볼 수 있습니다.
과거에는 command source ...를 사용했는데, 이는 lldb_commands 파일에서 일련의 CLI 명령을 실행하여 프로바이더를 추가합니다. 이 파일은 다소 다루기 불편했기 때문에, 아래에 설명하는 동등한 Python API 방식으로 대체되었습니다.
__lldb_init_module
이 함수는 다음과 같은 형태의 선택적 함수입니다:
def __lldb_init_module(debugger: SBDebugger, _lldb_internal) -> None: ...
이 함수는 command script import ...의 끝에서, 제어가 CLI로 돌아가기 전에 호출됩니다. 이를 통해 스크립트는 자체 상태를 초기화할 수 있습니다.
중요한 점은 이 함수에 디버거 자체에 대한 참조가 전달된다는 것입니다. 이를 통해 Rust 카테고리를 생성하고 여기에 프로바이더를 추가할 수 있습니다. 또한 스크립트가 감지한 LLDB 버전에 따라 사용할 프로바이더를 조건부로 변경할 수도 있습니다. 인식기는 lldb 19.0에서 추가되었으므로, 인식기 함수를 사용하기 시작하면 이는 하위 호환성을 위해 매우 중요합니다.
시각화 해석
시각화 도구가 결정되는 순서는 여기에 나와 있습니다. 요약하면 다음과 같습니다:
- 정확히 일치하는 항목(정규식이 아닌 이름, 인식기 함수, 또는 이미 프로바이더와 매칭된 타입)이 있으면 그것을 사용합니다
- 객체가 포인터/참조이면 역참조한 타입의 포매터를 사용해 봅니다.
- 객체가 typedef이면 기반 타입에 포매터가 있는지 확인합니다.
- 위의 방법이 모두 통하지 않으면 정규식 타입 매처를 차례로 순회합니다.
각 단계 안에서는 새 명령이 이전 명령을 “재정의“할 수 있도록 순회가 역순으로 이루어집니다. 이는 Box<str>와 Box<T>처럼 전자에는 특화된 합성을, 후자에는 더 일반화된 합성을 사용하려는 경우에 중요합니다.
세부 사항
LLDB의 API는 매우 강력하지만 몇 가지 “함정“과 직관적이지 않은 동작이 있으며, 그중 일부를 아래에서 설명합니다. Python 구현은 CLI 명령 lldb -P가 반환하는 경로의 lldb\__init__.py에서 확인할 수 있습니다. lldb 저장소의 예제 외에도 참고할 수 있는 C++ 시각화 도구가 있습니다(예: Vec<T>에 해당하는 LibCxxVector). C++의 시각화 도구는 C++로 작성되어 LLDB 내부에 접근할 수 있지만, API와 일반적인 관행은 매우 비슷합니다.
SBValue
- 포인터/참조
SBValue는 경우에 따라 사실상 “자동 역참조“되어, 가리키는 객체의 자식을 마치 자신의 자식인 것처럼 다룹니다. - 함수가 아닌 필드는 대개 결국 함수를 직접 가리키는
property()필드입니다(예:SBValue.type = property(GetType, None)). 이러한 축약형을 통해 접근하면 함수를 직접 호출하는 것보다 약간 느리므로 사용하지 않는 것이 좋습니다. 일부 프로퍼티는 특수한 프로퍼티를 가진 특수 객체를 반환합니다(예:SBValue.member는 자식에 접근하기 위해dict[str, SBValue]처럼 동작하는 객체를 반환합니다). 내부적으로 이러한 특수 객체 중 상당수는 새 클래스 인스턴스를 할당한 뒤 결국SBValue의 함수를 호출할 뿐이므로 추가적인 성능 손실이 발생합니다(예:SBValue.member는 내부적으로__getitem__만 구현하며, 이는 한 줄짜리 코드return self.valobj.GetChildMemberWithName(name)입니다) SBValue.GetID는 디버그 세션 동안 각 값마다 고유한int를 반환합니다. 합성(synthetic)SBValue는 그 기반이 되는SBValue와 다른 ID를 가집니다. 기반 ID는SBValue.GetNonSyntheticValue().GetID()를 통해 얻을 수 있습니다.- 주소를 수동으로 계산할 때는 타깃별 동작 때문에
SBValue.GetValueAsUnsigned보다SBValue.GetValueAsAddress를 사용하는 것이 좋습니다 SBValue의 문자열 표현을 얻는 것은 까다로울 수 있습니다.GetSummary는 요약 프로바이더를 필요로 하고,GetValue는 타입이 원시 타입으로 표현 가능해야 하기 때문입니다. 두 조건 중 어느 것도 충족되지 않는 거의 모든 경우, 해당 타입은StructSummaryProvider에 전달할 수 있는 사용자 정의 구조체입니다.
SBType
- “집합 타입(Aggregate type)“은 원시 타입이 아닌 구조체/클래스/공용체를 의미합니다
- “템플릿(Template)“은 “제네릭(Generic)“과 같은 의미입니다
- 타입은
SBTarget.FindFirstType(type_name)을 통해 이름으로 조회할 수 있습니다.SBTarget은SBValue.GetTarget을 통해 얻을 수 있습니다 SBType.template_args는 타입에 제네릭이 없으면 빈 리스트 대신None을 반환합니다- 때로는
SBType.GetArrayType이나SBType.GetPointerType같은 함수를 통해 타입을 원하는 타입으로 변환해야 합니다. 이 함수들은 실패하지 않습니다. 이 함수들은 디버그 정보를 완전히 우회하여 기반 LLDBTypeSystem플러그인에 해당 타입을 요청합니다. 디버그 정보에 해당 타입이 전혀 존재하지 않더라도 이 함수들은 적절한 타입을 생성할 수 있습니다. SBType.GetCanonicalType은 사실상SBType.GetTypedefedType+SBType.GetUnqualifiedType입니다.SBType.GetTypedefedType과 달리, 이 함수는 원래SBType이 typedef인지 여부와 관계없이 항상 유효한SBType을 반환합니다.SBType.GetStaticFieldWithName은 LLDB 18에서 추가되었습니다. 안타깝게도 정적 필드는 이 함수 없이는 전혀 접근할 수 없으므로, 하위 호환성을 항상 보장할 수는 없습니다.
예제 프로바이더: Vec<T>
SyntheticProvider
필드가 정해져 있으므로 __slots__를 사용하는 일반적인 서두부터 시작합니다. Vec의 힙 포인터는 *mut T가 아니라 *mut u8이므로, 객체 자체 외에 요소의 타입도 저장해야 합니다. Rust는 정적 타입 언어이므로 T의 타입은 절대 바뀌지 않습니다. 따라서 초기화 시점에 이를 저장할 수 있습니다. 하지만 힙 포인터, 길이, 용량은 바뀔 수 있으므로 여기서는 기본값으로 초기화합니다.
import lldb
class VecSyntheticProvider:
valobj: SBValue
data_ptr: SBValue
len: int
cap: int
element_type: SBType
__slots__ = (
"valobj",
"data_ptr",
"len",
"cap",
"element_type",
"__weakref__",
)
def __init__(valobj: SBValue, _dict) -> None:
self.valobj = valobj
# invalid type is a better default than `None`
self.element_type = SBType()
# special handling to account for DWARF/PDB differences
if (arg := valobj.GetType().GetTemplateArgumentType(0)):
self.element_type = arg
else:
arg_name = next(get_template_args(valobj.GetTypeName()))
self.element_type = resolve_msvc_template_arg(arg_name, valobj.GetTarget())
get_template_args와 resolve_msvc_template_arg의 구현은 lldb_providers.py를 참고하십시오.
다음은 update 함수입니다. 포인터나 길이가 바뀌었는지 확인합니다. 자식의 수는 len이 바뀌지 않는 한 그대로 유지되므로 용량 확인은 생략할 수 있습니다. 용량 변경으로 재할당이 일어났다면 data_ptr의 주소가 달라졌을 것입니다.
data_ptr, length 모두 바뀌지 않았다면 LLDB의 캐싱을 활용하여 일찍 반환할 수 있습니다. 바뀌었다면 새 값을 저장하고 LLDB에 캐시를 비우도록 지시합니다.
def update(self):
ptr = self.valobj.GetChildMemberWithName("data_ptr")
len = self.valobj.GetChildMemberWithName("length").GetValueAsUnsigned()
if (
self.data_ptr.GetValueAsAddress() == ptr.GetValueAsAddress()
and self.len == len
):
# Our child address offsets and child count are still valid
# so we can reuse cached children
return True
self.data_ptr = ptr
self.len = len
return False
has_children과 num_children은 모두 간단합니다:
def has_children(self) -> bool:
return True
def num_children(self) -> int:
return self.len
요소에 접근할 때는 인덱싱을 흉내 내기 위해 [0], [1] 등의 형식으로 된 값을 기대합니다. 또한 길이와 용량은 디버깅할 때 매우 유용할 수 있으므로, 사용자가 이 값들에도 빠르게 접근할 수 있게 하려 합니다. 요소 값과 겹치지 않는다고 거의 확실히 보장할 수 있으므로, 이 값들에 각각 u32::MAX - 1과 u32::MAX - 2를 할당합니다. capacity의 전체 이름과 축약 이름을 모두 처리할 수 있다는 점에 유의하십시오.
def get_child_index(self, name: str) -> int:
index = name.lstrip("[").rstrip("]")
if index.isdigit():
return int(index)
if name == "len":
return lldb.UINT32_MAX - 1
if name == "cap" or name == "capacity":
return lldb.UINT32_MAX - 2
return -1
이제 요소, 길이, 용량에 모두 접근할 수 있도록 get_child_at_index를 적절히 조정해야 합니다.
def get_child_at_index(self, index: int) -> SBValue:
if index == UINT32_MAX - 1:
return self.valobj.GetChildMemberWithName("len")
if index == UINT32_MAX - 2:
return (
self.valobj.GetChildMemberWithName("buf")
.GetChildMemberWithName("inner")
.GetChildMemberWithName("cap")
.GetChildAtIndex(0)
.Clone("capacity")
)
addr = self.data_ptr.GetValueAsAddress()
addr += index * self.element_type.GetByteSize()
return self.valobj.CreateValueFromAddress(f"[{index}]", addr, self.element_type)
타입의 표시 이름에서는 경로 한정자를 제거할 수 있습니다. Vec이라는 이름의 사용자 정의 타입은 완전한 경로로 표시되므로 모호성이 없을 것입니다. 할당자 제네릭도 유용한 경우가 매우 드물기 때문에 제거할 수 있습니다. 다음 3가지 이유로 self.element_type.GetName() 대신 get_template_args를 사용합니다:
- 어떤 이유로든 요소 타입을 해석하지 못하더라도,
self.valobj의 타입 이름으로 요소의 실제 타입이 무엇인지 사용자에게 알려 줄 수 있습니다 - 타입 이름은 DWARF 및 PDB 노드의 제약을 받지 않으므로, 이름 안의 템플릿 타입에는
*const/*mut와&/&mut같은 것이 반영됩니다. - 2025년 11월 기준으로 MSVC 타입 이름을 정규화하지 않지만, 정규화하게 되면 어차피 타입의 문자열 이름을 다뤄야 합니다. 또한 문자열에서 문자열로의 변환을 캐시하는 것이 `SBType`에서 문자열로의 변환을 캐시하는 것보다 훨씬 쉽습니다.
def get_type_name(self) -> str:
return f"Vec<{next(get_template_args(self.valobj))}>"
Vec을 표현할 적절한 원시 값이 없으므로 get_value 함수는 그냥 생략합니다.
SummaryProvider
합성 프로바이더 덕분에 요약 프로바이더는 매우 간단합니다. 유일한 문제는 객체의 타입에 SummaryProvider가 있을 때만 GetSummary가 값을 반환한다는 점입니다. 그렇지 않으면 빈 문자열을 반환하는데, 이는 바람직하지 않습니다. 완전한 시각화 스크립트 세트에서는 GetSummary()나 GetValue()가 없는 모든 타입이 구조체임을 보장한 다음, 제네릭 StructSummaryProvider에 위임할 수 있습니다. 이 시연에서는 그 세부 사항을 생략하겠습니다.
def VecSummaryProvider(valobj: SBValue, _lldb_internal) -> str:
children = []
for i in range(valobj.GetNumChildren()):
child = valobj.GetChildAtIndex(i)
summary = child.GetSummary()
if summary is None:
summary = child.GetValue()
if summary is None:
summary = "{...}"
children.append(summary)
return f"vec![{", ".join(children)}]"
프로바이더 활성화하기
이 합성 제공자를 lldb_lookup.py에 임포트했다고 가정합니다
CLI 명령을 사용하는 경우:
type synthetic add -l lldb_lookup.synthetic_lookup -x "^(alloc::([a-z_]+::)+)Vec<.+>$" --category Rust
type summary add -F lldb_lookup.summary_lookup -x "^(alloc::([a-z_]+::)+)Vec<.+>$" --category Rust
__lldb_init_module을 사용하는 경우:
def __lldb_init_module(debugger: SBDebugger, _dict: LLDBOpaque):
# Ensure the category exists and is enabled
rust_cat = debugger.GetCategory("Rust")
if not rust_cat.IsValid():
rust_cat = debugger.CreateCategory("Rust")
rust_cat.SetEnabled(True)
# Register Vec providers
vec_regex = r"^(alloc::([a-z_]+::)+)Vec<.+>$"
sb_name = lldb.SBTypeNameSpecifier(vec_regex, is_regex=True)
sb_synth = lldb.SBTypeSynthetic.CreateWithClassName("lldb_lookup.VecSyntheticProvider")
sb_synth.SetOptions(lldb.eTypeOptionCascade)
sb_summary = lldb.SBTypeSummary.CreateWithFunctionName("lldb_lookup.VecSummaryProvider")
sb_summary.SetOptions(lldb.eTypeOptionCascade)
rust_cat.AddTypeSynthetic(sb_name, sb_synth)
rust_cat.AddSummary(sb_name, sb_summary)
출력
프로바이더가 없는 경우:
(lldb) v vec_v
(alloc::vec::Vec<int, alloc::alloc::Global>) vec_v = {
buf = {
inner = {
ptr = {
pointer = (pointer = "\n")
_marker = {}
}
cap = (__0 = 5)
alloc = {}
}
_marker = {}
}
len = 5
}
(lldb) v vec_v[0]
error: <user expression 0>:1:6: subscripted value is not an array or pointer
1 | vec_v[0]
| ^
프로바이더가 있는 경우(v <var_name>은 요약을 출력한 다음 모든 자식의 목록을 출력합니다):
(lldb) v vec_v
(Vec<int>) vec_v = vec![10, 20, 30, 40, 50] {
[0] = 10
[1] = 20
[2] = 30
[3] = 40
[4] = 50
}
(lldb) v vec_v[0]
(int) vec_v[0] = 10
“숨겨진” 길이와 용량에 여전히 접근할 수 있다는 것도 확인할 수 있습니다:
(lldb) v vec_v.len
(unsigned long long) vec_v.len = 5
(lldb) v vec_v.capacity
(unsigned long long) vec_v.capacity = 5
(lldb) v vec_v.cap
(unsigned long long) vec_v.cap = 5
GDB - Python 프로바이더
아래는 GDB 문서의 관련 부분에 대한 링크입니다.
- 프리티 프린터 작성 개요
- 프리티 프린터 API(LLDB의
SyntheticProvider에 해당) - 값 API(LLDB의
SBValue에 해당) - 타입 API(LLDB의
SBType에 해당) - 타입 출력 API(LLDB의
SyntheticProvider.get_type_name에 해당)
CDB - Natvis
테스트
Important
디버그 정보 테스트 스위트는 대대적인 재작성이 진행 중입니다. 이 절은 재작성이 진행됨에 따라 채워질 예정입니다.
자세한 내용은 이 추적 이슈를 참고하십시오.
디버그 정보 테스트는 몇 가지 중요한 사항을 검사합니다:
- 정보를 우리가 예상한 방식으로 출력하고 있습니까?
- 우리가 출력한 내용을 디버거가 읽을 수 있습니까?
- 우리의 시각화 도구가 예상한 방식으로 동작합니까?
첫 번째 질문은 보통 tests/codegen-llvm에서 다루지만, 디버그 정보 생성은 의도적으로 테스트되기보다는 부수적으로 테스트되는 경우가 많습니다. 2026년 7월 기준으로 뒤의 두 질문에 훨씬 더 큰 비중을 두고 있으며, 여기에서는 이 두 질문을 자세히 다룹니다. 이 질문들에 답하는 테스트는 tests/debuginfo에 있으며, compiletest가 이를 실행합니다.
테스트 스위트가 존재해 온 기간의 대부분 동안 디버거는 자동으로 탐지되었고, 테스트는 변수 출력, 중단점 위치 등을 검사하는 $DEBUGGER-command 및 $DEBUGGER-check 지시문(즉, 원시 문자열 비교)으로 구성되었습니다. 솔직히 말해 이 시스템은 악몽과도 같았으며 수많은 문제를 초래했습니다. 이를 개선하기 위해 다음과 같은 조치를 취했습니다:
- GDB와 LLDB에 대해
tests/debuginfo는 이제 옵트인 방식입니다. - 새로운 지시문
$DEBUGGER-repr이 추가되었습니다. 이 지시문은 출력 결과에 보이지 않는 추가 정보를 디버거에 요청하는 사용자 정의 로직으로 처리를 넘깁니다. 또한 출력을 타깃별로 자동으로 분리하므로, 서로 다른 플랫폼에서 충돌 없이 테스트를 실행할 수 있습니다.
repr 지시문
Important
2026년 7월 현재, 이 명령은 LLDB에서만 지원됩니다. GDB 지원은 계획되어 있으나 아직 구현되지 않았습니다. 이 지시문이 앞으로 CDB에서 사용하기에 적합해질지는 불분명합니다.
요약하면, $DEBUGGER-repr 명령은 다음과 같이 디슈가링됩니다:
//@ $DEBUGGER-command:repr $VAR_NAME
//@ $DEBUGGER-check:$VAR_NAME ok
명령이 디버거에 전달될 때, 테스트 프레임워크는 repr 의사 명령을 가로채 특수 로직을 실행하며, tests/debuginfo/<test_name>/input/<debugger>_input/<target_group>.json에 저장된 데이터와 비교하여 테스트합니다.
“타깃 그룹“은 동일한 출력을 보장할 수 없는 타깃들의 집합을 포괄합니다. 이러한 타깃은 common.py의 Target 열거형에 정의되어 있습니다. 2026년 7월 기준으로 이 목록에는 non_windows, windows_gnu, windows_msvc가 포함됩니다. 각 타깃은 변경이 있을 때마다 갱신해야 하는 새로운 테스트 데이터 집합이므로, 이 목록은 의도적으로 가능한 한 짧게 유지합니다. 모든 타깃에 접근할 수 없는 기여자가 테스트를 --bless하는 방법에 대해서는 아직 완벽한 해결책이 없습니다.
테스트 실행 명령에 --bless를 추가하면(예: ./x test tests/debuginfo/basic-types/main.rs --bless) 예상된 변경 사항에 맞게 입력 데이터를 자동으로 갱신할 수 있습니다. --bless는 메모리 내 표현을 갱신하고, 이를 기준으로 테스트한 뒤, 오류가 발생하지 않으면 데이터를 타깃 파일에 다시 저장합니다(필요한 경우 새 파일을 생성합니다).
입력 데이터의 스키마는 common.py의 클래스들로 정의됩니다. 최상위 컨테이너는 TargetData입니다. 이 스키마는 모든 디버거에서 동일합니다.
기존 테스트 변환하기
변수를 테스트할 때는 거의 언제나 command + check보다 repr 지시문을 사용하는 것이 좋습니다. 2026년 7월 기준으로 변환된 테스트는 하나뿐이지만, 앞에서 언급한 테스트 재작성 과정에서 더 많은 테스트가 변환될 예정입니다. 다행히 변환 과정은 꽤 간단합니다. 다음과 같은 검사가 있다면:
//@ lldb-command:v foo
//@ lldb-check:<foo output>
이에 해당하는 repr 테스트는 다음과 같습니다:
//@ lldb-repr:foo
모든 command + checks를 repr로 변환했다면 --bless 옵션으로 테스트를 실행하십시오. 다른 타깃도 사용할 수 있다면 나머지 타깃 그룹의 데이터도 --bless하십시오(예를 들어 Windows 머신을 사용한다면 x86_64-pc-windows-msvc에 대해 한 번, x86_64-pc-windows-gnu에 대해 한 번 bless하고, WSL을 사용하여 x86_64-unknown-linux-gnu에 대해서도 bless하십시오).
구현
직렬화/역직렬화
TargetData는 dataclasses.asdict를 통해 딕셔너리로 변환된 후 Python의 내장 JSON 라이브러리로 직렬화됩니다. 테스트할 때는 데이터를 dict로 읽어 들여 TargetData로 변환한 뒤 최상위 INPUT_DATA 변수에 저장합니다. 현재의 역직렬화 로직은 스키마가 변경되어도 문제없이 동작해야 하지만, 모든 필드가 json.dumps로 직접 직렬화/역직렬화할 수 있는 타입“만“ 포함해야 합니다. 허용되는 타입은 common.JsonType을 구성하는 타입들입니다.
직렬화/역직렬화가 디버거 로직과 분리되어 있으므로, json보다 더 나은 대안을 찾으면 다른 형식으로 쉽게 전환할 수 있습니다.
디버거의 내부 표현을 스키마 클래스로 변환하는 로직은 from_$DEBUGGER.py에 있습니다.
common은 임포트되면 기존 입력 데이터를 자동으로 역직렬화하여 전역 변수 INPUT_DATA에 저장합니다. 테스트는 이 데이터를 기준으로 수행됩니다.
Note
repr명령이 실제로 처리되지 않는 한lldb_batchmode가common을 임포트하지 않도록 특별히 주의를 기울였습니다. 덕분에 입력 데이터가 필요 없는 테스트에서는 데이터를 읽거나 쓰지 않아도 됩니다.
형식 세부 사항
디버그 세션이 시작된 후에는 타입 정보가 고유하며 변하지 않으므로, 타입은 최상위에 한 번만 저장되고 그 밖의 모든 곳에서는 이름으로 참조됩니다.
포인터 값은 실행할 때마다 달라집니다. 불일치를 막기 위해 포인터 변수는 값을 저장하지 않습니다. 이는 -check 지시문에서 사용하는 와일드카드 [...]와 같습니다.
BlessMetadata는 TargetData에 포함되지만 테스트 대상은 아닙니다. 이는 오직 테스트 데이터가 어떻게 생성되었는지를 기록하기 위해 존재하며, Python이나 디버거의 버전 변경으로 발생할 수 있는 문제를 진단하는 데 도움을 줍니다.
진입점과 --bless
repr 의사 명령을 만나면 lldb_batchmode.main은 check_$DEBUGGER.check()로 처리를 넘깁니다. --bless 옵션이 지정된 경우, 변수는 메모리 내 표현에서 이에 대응하는 스키마 클래스로 변환됩니다. 여기에는 변수의 타입, 시각화 도구, 자식, 자식의 타입 등이 포함됩니다. TargetData에 삽입되고 나면, 변수는 방금 TargetData에 저장된 데이터와 비교하여 테스트됩니다.
예외나 오류가 발생하지 않았고 --bless 옵션이 지정된 경우, lldb_batchmode가 종료되기 직전에 INPUT_DATA가 적절한 파일에 기록됩니다. 오류가 발생하면 INPUT_DATA는 그냥 폐기됩니다.
현재 repr 의사 명령은 직접 검사하여 식별합니다. GDB와 LLDB는 모두 Python 코드를 통해 사용자 정의 CLI 명령을 만드는 기능을 지원합니다. 향후에는 repr이 두 디버거 중 하나 또는 둘 모두의 CLI 명령으로 구현될 수 있습니다.
검사 로직
check_$DEBUGGER.check는 디버거의 변수 객체를 Variable 객체로 변환한 뒤 둘을 비교합니다. 불일치가 발견되면 오류를 더 유용한 방식으로 보고하기 위해 추가 처리를 수행합니다. 즉, 오류는 발생하는 즉시 감지되고 보고되며, 여기에는 여러 가지 장점이 있습니다. 가장 중요한 점은, 실패 이후 디버거 상태가 바뀌지 않았고 디버거의 변수 객체에 여전히 접근할 수 있으므로, 디버거에 추가 정보를 요청하여 더 유용한 오류 메시지를 제공할 수 있다는 것입니다.
예를 들어, LLDB는 synthetic/summary 프로바이더 호출 내에서 발생하는 오류를 잘 드러내지 않는 경향이 있습니다. 이는 다른 명령 안에서 명령을 실행할 때 특히 그러한데, 테스트가 바로 script import lldb_batchmode; lldb_batchmode.main()을 호출하고 그 컨텍스트에서 명령을 실행하는 방식으로 동작합니다.
오류를 만나면 적절한 summary 프로바이더를 임포트하여 변수 객체를 전달하고, 예외를 직접 출력할 수 있습니다. 또한 합성 프로바이더 클래스를 검사하여 필수 함수를 모두 구현하고 있는지 확인할 수도 있습니다.
오류가 발생해도 테스트가 즉시 종료되지 않습니다. 이는 --bless 옵션이 추가된 지금 특히 중요합니다. --bless는 모든 입력 데이터를 갱신하므로, 읽는 사람이 추가로 변경해야 할 사항이 있는지 충분한 정보를 바탕으로 판단할 수 있도록 모든 오류를 출력해야 합니다. 첫 번째 오류가 우연히 예상된 변경이었다는 이유만으로 사람들이 잘못된 데이터를 실수로 승인(bless)하는 일은 절대 없어야 합니다.
오류는 repr 의사 명령의 가시적인 출력으로 나타나도록 stdout에 직접 출력됩니다. 형식을 일관되게 유지하기 위한 여러 오류 도우미 함수가 있습니다.
Note
LLDB는
script명령을 실행할 때 Python 인터프리터의stderr를 출력하지 않습니다. 인터프리터가 예외와 함께 종료되면 그 예외는 출력하지만,stderr의 나머지 내용은 전혀 출력하지 않습니다. 대신stderr에 출력하고 싶다면, Pythonio.TextIoWrapper를 반환하는lldb.debugger.GetErrorFileHandle을 호출하여 디버거의 것을 사용할 수 있습니다.
특정 변수에 대해 오류가 발생하지 않았다면, compiletest가 대조할 수 있도록 $VAR_NAME ok가 stdout에 출력됩니다.
lldb_batchmode가 종료되기 전에, INPUT_DATA에 있던 모든 타입과 변수가 검사되었는지 확인하는 마지막 검사가 수행됩니다. 이 검사가 실패하면 스크립트는 테스트되지 않은 타입/변수를 보고하고 오류 코드와 함께 종료합니다.
LLDB 버전 관리
Apple은 Swift 지원이 포함된 LLDB 포크를 Xcode와 함께 배포합니다. 이 LLDB 포크는 LLVM의 LLDB와 같은 버전 체계를 사용하지 않습니다:
# Apple:
lldb-1703.0.236.21 Apple Swift version 6.2.3 (swiftlang-6.2.3.3.21 clang-1700.6.3.2)
# LLVM:
lldb version 22.1.2 (https://github.com/llvm/llvm-project revision 1ab49a973e210e97d61e5db6557180dcb92c3e98)
clang revision 1ab49a973e210e97d61e5db6557180dcb92c3e98
llvm revision 1ab49a973e210e97d61e5db6557180dcb92c3e98
Apple LLDB의 버전은 LLVM의 버전에서 파생된 것으로 보이지 않으므로, 둘 사이를 쉽게 또는 자동으로 변환할 수 없습니다. 다행히도 Swift LLVM 저장소에서 해당하는 릴리스 브랜치를 확인하면 기반 LLVM 버전을 수동으로 확인할 수는 있습니다. 위 예시의 경우 Swift 6.2.3에 대한 브랜치는 존재하지 않지만, 6.2.2에 대한 브랜치는 있습니다. LLVM 버전은 llvm/utils/gn/secondary/llvm/version.gni에 있습니다. 해당 예시에서 볼 수 있듯이, 위의 Apple LLDB 버전은 (대략) LLVM LLDB 19.1.5에 해당합니다.
이를 통해 CI에서 사용되는 Apple LLDB에서 어떤 기능을 사용할 수 있는지 더 잘 파악할 수 있으므로, 문제를 진단하거나 새 테스트를 작성할 때 유용할 수 있습니다. 예를 들어 LLDB 19는 Type Recognizer 함수를 지원한 최초의 버전이었으므로, 예시로 든 Apple LLDB가 이를 지원한다고 가정할 수 있습니다.
Rust 컴파일러의 디버깅 지원
이 문서는 Rust 컴파일러(rustc)의 디버깅 도구 지원 현황을 설명합니다. GDB, LLDB, WinDbg/CDB에 대한 개요와 함께 Rust 코드를 디버깅하기 위한 Rust 컴파일러 주변 인프라를 다룹니다. Rust 컴파일러 자체를 디버깅하는 방법을 알고 싶다면 컴파일러 디버깅하기를 참조하십시오.
이 자료는 Tom Tromey가 rustc의 디버깅 지원에 대해 논의하는 영상에서 수집한 것입니다.
사전 지식
디버거
위키백과에 따르면
디버거 또는 디버깅 도구는 다른 프로그램(“대상” 프로그램)을 테스트하고 디버깅하는 데 사용되는 컴퓨터 프로그램입니다.
한 언어를 위한 디버거를 처음부터 작성하려면 많은 작업이 필요하며, 특히 디버거가 다양한 플랫폼에서 지원되어야 한다면 더욱 그렇습니다. 그러나 GDB와 LLDB는 특정 언어의 디버깅을 지원하도록 확장할 수 있습니다. 이것이 Rust가 선택한 경로입니다. 이 문서의 주요 목표는 Rust 컴파일러에서 앞서 언급한 디버거들을 지원하는 방식을 문서화하는 것입니다.
DWARF
DWARF 표준 웹사이트에 따르면
DWARF는 소스 수준 디버깅을 지원하기 위해 많은 컴파일러와 디버거에서 사용하는 디버깅 파일 형식입니다. C, C++, Fortran과 같은 여러 절차적 언어의 요구 사항을 충족하며, 다른 언어로도 확장할 수 있도록 설계되었습니다. DWARF는 아키텍처에 독립적이며 어떤 프로세서나 운영 체제에도 적용할 수 있습니다. DWARF는 Unix, Linux 및 기타 운영 체제뿐만 아니라 독립 실행형 환경에서도 널리 사용됩니다.
DWARF 리더는 DWARF 형식을 읽어 디버거와 호환되는 출력을 생성하는 프로그램입니다. 이 프로그램은 컴파일러 자체에 포함될 수도 있습니다. DWARF는 디버깅 정보 항목(Debugging Information Entry, DIE)이라는 자료 구조를 사용합니다. DIE는 함수, 변수 등을 나타내는 “태그“로 정보를 저장하며, 예를 들면 DW_TAG_variable, DW_TAG_pointer_type, DW_TAG_subprogram 등이 있습니다. 직접 태그와 속성을 새로 만들 수도 있습니다.
CodeView/PDB
PDB(Program Database)는 Microsoft가 만든 파일 형식으로, 디버그 정보를 담고 있습니다. WinDbg/CDB와 같은 디버거 및 기타 도구는 PDB를 읽어 디버그 정보를 표시할 수 있습니다. PDB에는 특정 바이너리에 대한 디버그 정보를 기술하는 여러 스트림이 들어 있으며, 여기에는 해당 바이너리를 컴파일하는 데 사용된 타입, 심볼, 소스 파일 등이 포함됩니다. CodeView는 PDB 스트림 안에 나타나는 심볼 레코드와 타입 레코드의 구조를 정의하는 또 다른 형식입니다.
지원되는 디버거
GDB
Rust 표현식 파서
디버그 출력을 표시하려면 표현식 파서가 필요합니다. 이 (GDB) 표현식 파서는 Bison으로 작성되었으며, Rust 표현식의 일부만 파싱할 수 있습니다. GDB 파서는 처음부터 새로 작성되었으며, rustc의 파서를 포함한 다른 어떤 파서와도 관련이 없습니다.
GDB는 Rust와 유사한 형태로 값과 타입을 출력합니다. GDB는 값과 타입을 Rust 구문처럼 보이는 방식으로 출력할 수 있습니다. 또한 GDB에서 ptype으로 타입을 출력하면 그 결과도 Rust 소스 코드처럼 보입니다. GDB/Rust 매뉴얼의 문서를 확인하십시오.
파서 확장
표현식 파서에는 Rust로는 할 수 없는 기능을 지원하기 위한 몇 가지 확장이 포함되어 있습니다. 일부 제약 사항은 GDB/Rust 매뉴얼에 나열되어 있습니다. GDB의 DWARF 리더에는 이러한 확장을 지원하기 위한 특수한 코드가 일부 있습니다.
필요한 DWARF 리더 지원의 몇 가지 예는 다음과 같습니다.
-
열거형: 열거형 타입을 지원하는 데 필요합니다. Rust 컴파일러는 열거형에 대한 정보를 DWARF에 기록하고, GDB는 DWARF를 읽어 태그 필드가 어디에 있는지, 태그 필드가 존재하는지, 태그 슬롯이 non-zero 최적화와 공유되는지 등을 파악합니다.
-
트레이트 객체 분석: DWARF 안의 트레이트 객체 설명이 해당 vtable의 스텁 설명도 가리키고, 이 스텁 설명이 다시 해당 트레이트 객체가 존재하는 구체 타입을 가리키도록 하는 DWARF 확장입니다. 즉, 해당 트레이트 객체에 대해
print *object를 실행할 수 있으며, GDB는 트레이트 객체 안에 있는 페이로드의 올바른 타입을 찾는 방법을 파악합니다.
TODO: 중복이 없도록 다음 내용을 이 가이드 페이지 대신 GDB-Rust 문서에서 언급해야 하는지 확인하십시오. 이는 다음 코멘트들과 관련된 것입니다.
gdb의 Rust 확장과 제약 사항은 gdb 매뉴얼에 문서화되어 있습니다: https://sourceware.org/gdb/onlinedocs/gdb/Rust.html – 다만 이 매뉴얼은 gdb 편의 변수와 레지스터가 gdb의 $ 규칙을 따른다는 점과, Rust 파서가 gdb의 @ 확장을 구현한다는 점을 언급하지 않습니다.
@tromey 중복 등을 피하기 위해 이 부분을 이 문서 대신 GDB-Rust 문서에서 언급해야 한다고 생각하십니까?
LLDB
Rust 표현식 파서
이 표현식 파서는 C++로 작성되었습니다. 이 파서는 재귀 하향 파서의 일종입니다. 이 파서는 GDB보다 Rust 언어를 약간 덜 구현합니다. LLDB는 Rust와 유사한 형태로 값과 타입을 출력합니다.
개발자 노트
- LLDB는 플러그인 아키텍처를 가지고 있지만, 이 아키텍처는 언어 지원에는 사용할 수 없습니다.
- 일반적으로 Linux에서는 GDB가 더 잘 동작합니다.
WinDbg/CDB
Microsoft는 Windows 디버거(WinDbg)와 콘솔 디버거(CDB) 같은 Windows 디버깅 도구를 제공하며, 두 디버거 모두 Rust로 작성된 프로그램의 디버깅을 지원합니다. 이 디버거들은 PDB가 있으면 거기에서 바이너리의 디버그 정보를 파싱하여 디버거에 표시할 시각화를 구성합니다.
Natvis
WinDbg와 CDB는 모두 Natvis 프레임워크를 사용하여 디버거 안에서 임의의 타입에 대한 사용자 정의 시각화를 정의하고 볼 수 있도록 지원합니다. Rust 컴파일러는 std, core, alloc 등 표준 라이브러리에 있는 일부 타입에 대한 사용자 정의 시각화를 정의하는 Natvis 파일 모음을 정의합니다. 이 Natvis 파일들은 *-pc-windows-msvc 타깃 트리플로 생성된 PDBs에 내장되어, 디버깅할 때 이러한 사용자 정의 시각화가 자동으로 활성화되도록 합니다. 이 기본 동작은 strip rustc 플래그를 debuginfo 또는 symbols로 설정하여 재정의할 수 있습니다.
Rust는 #[debugger_visualizer] 속성을 사용하여 표준 라이브러리 외부의 크레이트에 대한 Natvis 파일을 내장하는 기능을 지원합니다. 디버거 시각화 도구를 내장하는 방법에 대한 자세한 내용은 debugger_visualizer 속성 절을 참고하십시오.
DWARF와 rustc
DWARF는 디버거가 읽는 디버깅 정보를 컴파일러가 생성하는 표준 방식입니다. macOS와 Linux에서는 바로 이것이 디버깅 형식입니다. DWARF는 여러 언어를 지원하는 확장 가능한 형식이며, Rust의 용도에도 대체로 충분합니다. 따라서 현재 구현은 DWARF의 개념을 재사용합니다. DWARF의 일부 개념이 의미상 Rust와 맞지 않더라도 이는 마찬가지인데, 일반적으로 둘 사이에 어떤 형태로든 대응 관계를 만들 수 있기 때문입니다.
Rust 컴파일러가 내보내고 디버거가 이해하지만 DWARF 표준에는 없는 DWARF 확장이 몇 가지 있습니다.
-
Rust 컴파일러는 가상 테이블에 대한 DWARF를 내보내며, 이
vtable객체는 실제 타입을 가리키는DW_AT_containing_type을 가집니다. 이를 통해 디버거는 트레이트 객체 포인터를 분해하여 페이로드를 올바르게 찾을 수 있습니다. 다음은 gdb 저장소의 테스트 케이스에서 가져온 그러한 DIE의 예시입니다.<1><1a9>: Abbrev Number: 3 (DW_TAG_structure_type) <1aa> DW_AT_containing_type: <0x1b4> <1ae> DW_AT_name : (indirect string, offset: 0x23d): vtable <1b2> DW_AT_byte_size : 0 <1b3> DW_AT_alignment : 8 -
또 다른 확장은 Rust 컴파일러가 태그 없는 판별 공용체(tagless discriminated union)를 내보낼 수 있다는 것입니다. 이 항목에 대해서는 DWARF 기능 요청을 참고하십시오.
DWARF의 현재 한계
- 트레이트 - DWARF에서 트레이트를 표현하는 방법과 관련하여 DWARF에 평소보다 큰 변경이 필요합니다.
- DWARF는 구조체와 튜플을 구별할 방법을 제공하지 않습니다. 이 한계를 극복하기 위해 Rust 컴파일러는
__0과 같은 이름으로 필드를 내보내고, 디버거는 그러한 이름이 연속으로 나타나는지 찾습니다. 예를 들어 이 경우 디버거는x.0대신x.__0을 통해 필드를 조회합니다. 이 문제는 디버거 안의 Rust 파서를 통해 해결되었으므로 이제x.0을 사용할 수 있습니다.
DWARF는 디버거가 플랫폼 ABI에 관한 일부 정보를 알고 있다고 전제합니다. Rust는 항상 그렇게 하지는 않습니다.
개발자 노트
이 절은 개발의 특정 측면에 관한 발표 내용에서 가져왔습니다.
부족한 부분
macOS에서 LLDB 디버그 서버의 코드 서명
위키백과에 따르면 System Integrity Protection은 다음과 같습니다.
System Integrity Protection(SIP, rootless라고도 함)은 OS X El Capitan에서 도입된 Apple macOS 운영 체제의 보안 기능입니다. 이 기능은 커널이 강제하는 여러 메커니즘으로 구성됩니다. 핵심은 특정 “권한(entitlement)“이 없는 프로세스가 시스템 소유의 파일과 디렉터리를 수정하지 못하도록 보호하는 것이며, 이는 root 사용자나 root 권한을 가진 사용자(sudo)가 실행하는 경우에도 마찬가지입니다.
이 기능은 프로세스가 ptrace 시스템 호출을 사용하지 못하게 합니다. 프로세스가 ptrace를 사용하려면 코드 서명이 되어 있어야 합니다. 서명에 사용한 인증서는 여러분의 컴퓨터에서 신뢰할 수 있는 것이어야 합니다.
System Integrity Protection에 관한 Apple 개발자 문서를 참고하십시오.
이 서명을 하려면 Apple에 등록하고 키를 발급받아야 할 수도 있습니다. Tom은 Mozilla가 서명할 수 있는 키의 최대 개수에 이미 도달해서 이 작업을 할 수 없는지 알아보았습니다. Tom은 Mozilla가 키를 더 받을 수 있는지는 알지 못합니다.
다른 방법으로, Tom은 Apple을 통해 키를 받으려면 Rust 법인이 필요할 수도 있다고 제안합니다. 이 문제는 본질적으로 기술적인 문제가 아닙니다. 그러한 키가 있다면 GDB에도 서명하여 함께 배포할 수 있을 것입니다.
DWARF와 트레이트
Rust 트레이트는 DWARF에 전혀 출력되지 않습니다. 그 결과 x.method()와 같은 메서드 호출이 그대로는 동작하지 않습니다. 그 이유는 해당 메서드가 타입이 아니라 트레이트에 의해 구현되기 때문입니다. 그 정보가 없으므로 트레이트 메서드를 찾을 수 없습니다.
DWARF에는 인터페이스 타입이라는 개념이 있습니다(아마 Java를 위해 추가된 것으로 보입니다). Tom의 아이디어는 이 인터페이스 타입을 트레이트로 사용하는 것이었습니다.
DWARF는 참조 타입이 아닌 구체적인 이름만 다룹니다. 따라서 어떤 타입에 대한 트레이트의 특정 구현은 이러한 인터페이스(DW_tag_interface 타입) 중 하나가 됩니다. 또한 트레이트가 구현된 타입은 해당 타입이 구현하는 모든 인터페이스를 기술하게 됩니다. 이를 위해서는 DWARF 확장이 필요합니다.
GitHub 이슈: https://github.com/rust-lang/rust/issues/33014
디버그 정보 변경의 일반적인 절차(LLVM)
LLVM에는 디버그 정보(Debug Info, DI) 빌더가 있습니다. 이것이 Rust가 호출하는 주된 대상입니다. DWARF가 직접 방출되는 것이 아니라 이것이 먼저 방출되기 때문에 LLVM을 먼저 변경해야 합니다. 이것은 직접 구성하여 LLVM에 넘겨주는 일종의 메타데이터입니다. Rustc에서 LLVM으로 넘겨줄 때에는 타입의 표현을 구성하기 위해 몇몇 LLVM DI 빌더 메서드가 호출됩니다.
이 과정의 단계는 다음과 같습니다.
-
LLVM을 변경해야 합니다.
LLVM은 Interface 타입을 전혀 방출하지 않으므로, 이를 먼저 LLVM에 구현해야 합니다.
이것이 좋은 아이디어라는 데 대해 LLVM 메인테이너들의 승인을 받으십시오.
-
DWARF 확장을 변경하십시오.
-
디버거를 업데이트하십시오.
DWARF 리더와 표현식 평가기를 업데이트하십시오.
-
Rust 컴파일러를 업데이트하십시오.
이 새로운 정보를 방출하도록 변경하십시오.
절차적 매크로 스테핑
매우 근본적인 질문은 절차적 매크로를 실제로 어떻게 디버깅하느냐는 것입니다. 매크로 확장에 대해 어떤 위치를 방출해야 합니까? 다음과 같은 몇 가지 경우를 생각해 보십시오.
- 매크로 호출 위치를 방출할 수 있습니다.
- 매크로 정의 위치를 방출할 수 있습니다.
- 매크로 내용의 위치들을 방출할 수 있습니다.
RFC: https://github.com/rust-lang/rfcs/pull/2117
핵심은 무엇을 할지 매크로가 결정하도록 하는 것입니다. 이는 라인 마커가 어디에 있어야 하는지 매크로가 컴파일러에 알려 줄 수 있게 하는 일종의 속성(attribute)을 두어 달성할 수 있습니다. 이는 중단점을 설정하는 위치와 스텝 실행 시 일어나는 일에 영향을 줍니다.
디버그 정보의 소스 파일 체크섬
DWARF와 CodeView(PDB)는 모두 해당 바이너리를 만드는 데 사용된 각 소스 파일의 암호학적 해시를 포함하는 기능을 지원합니다.
디버거는 암호학적 해시를 사용하여 소스 파일이 실행 파일과 일치하는지 확인할 수 있습니다. 소스 파일이 일치하지 않으면 디버거는 사용자에게 경고를 표시할 수 있습니다.
해시는 특정 소스 파일이 실행 파일을 컴파일하는 데 사용된 이후로 수정되지 않았음을 증명하는 데에도 사용할 수 있습니다. MD5와 SHA1은 모두 취약점이 입증되었으므로, 이 용도에는 SHA256을 사용하는 것이 권장됩니다.
Rust 컴파일러는 각 소스 파일의 해시를 SourceMap 안의 해당 SourceFile에 저장합니다. 외부 크레이트의 입력 파일 해시는 rlib 메타데이터에 저장됩니다.
기본 해싱 알고리즘은 타깃 명세에서 설정됩니다. 모든 타깃이 모든 해시 알고리즘을 지원하지는 않으므로, 이를 통해 각 타깃은 사용 가능한 가장 좋은 해시를 지정할 수 있습니다.
타깃의 해싱 알고리즘은 -Z source-file-checksum= 명령줄 옵션으로 재정의할 수도 있습니다.
DWARF 5
DWARF 버전 5는 사용 중인 소스 파일 버전을 검증할 수 있도록 MD5 해시를 포함하는 기능을 지원합니다. DWARF 5 - 6.2.4.1절 opcode DW_LNCT_MD5
LLVM
LLVM IR은 DIFile 노드에서 MD5 및 SHA1 소스 파일 체크섬을 지원합니다(LLVM 11 이상에서는 SHA256도 지원합니다).
Microsoft Visual C++ 컴파일러 /ZH 옵션
MSVC 컴파일러는 /ZH 컴파일러 옵션을 사용하여 PDB에 MD5, SHA1 또는 SHA256 해시를 포함하는 기능을 지원합니다.
Clang
문서에는 나와 있지 않지만, Clang은 항상 MD5 체크섬을 포함합니다.
향후 작업
이름 맹글링 변경
libiberty(gcc 소스 트리)의 새 디맹글러입니다.- LLVM 또는 LLDB의 새 디맹글러입니다.
TODO: 디맹글러 소스의 위치를 확인하십시오. #1157
표현식 평가에 Rust 컴파일러 재사용
디버거는 대체로 타입 추론을 구현하려 하지 않기 때문에 이는 중요한 아이디어입니다. 디버거에 입력할 때는 실제 소스 코드를 작성할 때보다 훨씬 더 명시적으로 작성해야 합니다. 따라서 소스 코드의 표현식을 디버거에 그대로 복사해 붙여 넣고 같은 결과를 기대할 수는 없지만, 그렇게 할 수 있다면 좋을 것입니다. 컴파일러를 사용하면 이 문제를 개선할 수 있습니다.
충분히 가능한 일이지만, 규모가 큰 프로젝트입니다. 메모리에 접근할 수 있는 것은 디버거뿐이므로 디버거와 연결하는 계층이 반드시 필요합니다. GDB(gcc)와 LLDB(clang) 모두 이 기능을 갖추고 있습니다. LLDB는 Clang을 사용해 코드를 JIT로 컴파일하며, GDB도 GCC로 같은 작업을 할 수 있습니다.
두 디버거의 표현식 평가는 모두 Rust의 상위 집합이면서 동시에 하위 집합을 구현합니다. 이들은 표현식 언어만 구현하지만, GDB의 편의 변수(convenience variable)와 같은 몇 가지 확장 기능도 추가합니다. 따라서 이 방식을 택한다면 이러한 연결 작업을 해야 할 뿐만 아니라, 컴파일러가 일부 확장 기능을 이해할 수 있도록 별도의 모드를 추가해야 할 수도 있습니다.
라이브러리와 메타데이터
컴파일러가 외부 크레이트에 대한 참조를 만나면 해당 크레이트에 대한 몇 가지 정보를 불러와야 합니다. 이 장에서는 그 과정과 크레이트 라이브러리에 지원되는 파일 형식을 개괄적으로 설명합니다.
라이브러리
크레이트 의존성은 rlib, dylib, 또는 rmeta 파일에서 불러올 수 있습니다. 이 파일 형식들의 핵심은 rustc 고유의 메타데이터를 담고 있다는 점입니다. 이 메타데이터 덕분에 컴파일러는 외부 크레이트에 대해 충분한 정보를 파악하여, 그 크레이트에 어떤 항목이 들어 있는지, 어떤 매크로를 내보내는지 등 훨씬 더 많은 것을 이해할 수 있습니다.
rlib
rlib는 tar 파일과 비슷한 아카이브 파일입니다. 이 파일 형식은 rustc에 특화되어 있으며, 시간이 지나면서 바뀔 수 있습니다. 이 파일에는 다음이 들어 있습니다:
- 코드 생성의 결과물인 오브젝트 코드입니다. 이는 일반적인 링크 과정에서 사용됩니다. 각 코드 생성 단위마다 별도의
.o파일이 있습니다.-C linker-plugin-ltoCLI 옵션을 사용하면 코드 생성 단계를 건너뛸 수 있으며, 이 경우 각.o파일에는 LLVM 비트코드만 들어 있게 됩니다. - LLVM 중간 표현의 바이너리 형태인 LLVM 비트코드로,
.o파일 안에 하나의 섹션으로 포함됩니다. 이는 링크 타임 최적화(LTO)에 사용할 수 있습니다. LTO가 필요하지 않다면-C embed-bitcode=noCLI 옵션으로 이를 제거하여 컴파일 시간을 단축하고 디스크 공간을 줄일 수 있습니다. lib.rmeta라는 이름의 파일에 담긴rustc메타데이터입니다.- 심볼 테이블로, 본질적으로 각 심볼과 그 심볼을 포함하는 오브젝트 파일에 대한 오프셋을 나열한 목록입니다. 이는 아카이브 파일에서 꽤 일반적인 구성입니다.
dylib
dylib는 플랫폼별 공유 라이브러리입니다. 여기에는 .rustc라는 특수한 링크 섹션에 rustc 메타데이터가 포함되어 있습니다.
rmeta
rmeta 파일은 크레이트의 메타데이터를 담고 있는 사용자 정의 바이너리 형식입니다. 이 파일은 모든 코드 생성을 건너뛰어 프로젝트를 빠르게 “검사“하거나(cargo check에서처럼), 문서화에 필요한 충분한 정보를 수집하거나(cargo doc에서처럼), 파이프라이닝에 사용할 수 있습니다. 이 파일은 --emit=metadata CLI 옵션을 사용하면 생성됩니다.
rmeta 파일에는 컴파일된 오브젝트 파일이 들어 있지 않으므로 링크를 지원하지 않습니다.
메타데이터
메타데이터에는 매우 다양한 요소가 들어 있습니다. 이 가이드에서는 메타데이터에 들어 있는 필드를 하나하나 자세히 다루지는 않습니다. 메타데이터에 어떤 요소들이 들어 있는지 파악하려면 CrateRoot 정의를 살펴보시기 바랍니다. 메타데이터 인코딩과 디코딩에 관한 모든 내용은 rustc_metadata 패키지에 있습니다.
메타데이터에 들어 있는 주요 항목 몇 가지는 다음과 같습니다.
rustc컴파일러의 버전입니다. 컴파일러는 다른 버전에서 만들어진 파일을 로드하지 않습니다.- 엄격한 버전 해시(Strict Version Hash, SVH)입니다. 이는 올바른 의존성이 로드되도록 보장하는 데 도움이 됩니다.
- 안정 크레이트 ID(Stable Crate Id)입니다. 이는 크레이트를 식별하는 데 사용하는 해시입니다.
- 라이브러리에 포함된 모든 소스 파일에 관한 정보입니다. 이 정보는 의존성의 소스를 가리키는 진단 메시지 등 다양한 용도로 사용할 수 있습니다.
- 내보낸 매크로, 트레이트, 타입, 아이템에 관한 정보입니다. 일반적으로 경로가 크레이트 의존성 내부의 무언가를 참조할 때 알아야 하는 모든 정보가 여기에 해당합니다.
- 인코딩된 MIR입니다. 이 항목은 선택 사항이며 코드 생성에 필요한 경우에만 인코딩됩니다.
cargo check는 성능상의 이유로 이 항목을 건너뜁니다.
엄격한 버전 해시
엄격한 버전 해시(SVH, “크레이트 해시“라고도 합니다)는 올바른 크레이트 의존성이 로드되도록 보장하는 데 사용하는 64비트 해시입니다. 한 디렉터리에 같은 의존성을 서로 다른 설정이나 서로 다른 소스로 빌드한 사본이 여러 개 들어 있을 수 있습니다. 크레이트 로더는 SVH가 맞지 않는 크레이트를 모두 건너뜁니다.
SVH는 증분 컴파일 세션 파일 이름에도 사용되지만, 이는 대체로 과거의 흔적으로 남아 있는 용도입니다.
이 해시에는 다양한 요소가 포함됩니다.
- HIR 노드의 해시입니다.
- 모든 업스트림 크레이트의 해시입니다.
- 모든 소스 파일 이름입니다.
- 특정 명령줄 플래그의 해시(Stable Crate Id를 통한
-C metadata와 같은 플래그, 그리고[TRACKED]로 표시된 모든 CLI 옵션)입니다. 이 해시는 크레이트를 식별하는 데 사용됩니다.
해시가 실제로 계산되는 위치는 compute_hir_hash를 참고하십시오.
안정 크레이트 ID
StableCrateId는 이름이 같을 수도 있는 서로 다른 크레이트를 식별하는 데 사용하는 64비트 해시입니다. 이는 크레이트 이름과 모든 -C metadata CLI 옵션을 해시한 값으로, StableCrateId::new에서 계산됩니다. 이 해시는 심볼 이름 맹글링, 크레이트 로딩 등 다양한 곳에서 사용됩니다.
기본적으로 모든 Rust 심볼은 맹글링되며 안정 크레이트 ID를 포함합니다. 이를 통해 같은 크레이트의 여러 버전을 함께 포함할 수 있습니다. Cargo는 패키지 버전, 소스, 타깃 종류 등 다양한 요소를 바탕으로 -C metadata 해시를 자동으로 생성합니다(라이브러리와 테스트는 같은 크레이트 이름을 가질 수 있으므로 서로 구분해야 합니다).
크레이트 로딩
크레이트 로딩에는 미묘하게 복잡한 부분이 꽤 많습니다. 이름 해석 중에 외부 크레이트가 (extern crate나 경로를 통해) 참조되면, 해석기는 크레이트 라이브러리를 찾고 그 메타데이터를 로드하는 일을 맡은 CStore를 사용합니다. 의존성이 로드되면 CStore는 해석기가 작업(매크로 확장, 경로 해석 등)을 수행하는 데 필요한 정보를 제공합니다.
각 외부 크레이트를 로드하기 위해 CStore는 CrateLocator를 사용하여 특정 크레이트 하나에 대한 올바른 파일을 실제로 찾습니다. locator 모듈에는 로딩이 어떻게 동작하는지 자세히 설명하는 훌륭한 문서가 있으며, 전체 그림을 파악하려면 이 문서를 읽어 보시기를 강력히 권합니다.
의존성의 위치는 여러 곳에서 올 수 있습니다. 직접 의존성은 보통 --extern 플래그로 전달되며, 로더는 이를 직접 살펴볼 수 있습니다. 직접 의존성은 흔히 자기 자신의 의존성을 참조하며, 이들 역시 로드되어야 합니다. 이들은 보통 -L 플래그로 전달된 디렉터리를 탐색하여, 메타데이터에 일치하는 크레이트 이름과 SVH가 포함된 파일을 찾는 방식으로 발견됩니다. 로더는 의존성을 찾기 위해 sysroot도 살펴봅니다.
크레이트가 로드되면, 크레이트 메타데이터가 CrateMetadata 구조체로 감싸진 채 CStore에 보관됩니다. 이름 해석과 확장이 끝나면 CStore는 나머지 컴파일 과정을 위해 GlobalCtxt로 옮겨집니다.
파이프라이닝
컴파일 시간을 개선하는 한 가지 방법은 의존성의 메타데이터를 사용할 수 있게 되는 즉시 크레이트 빌드를 시작하는 것입니다. 라이브러리의 경우 의존성의 코드 생성이 끝날 때까지 기다릴 필요가 없습니다. Cargo는 rustc에 각 의존성에 대해 rlib뿐만 아니라 rmeta 파일도 생성하도록 지시하는 방식으로 이 기법을 구현합니다. rustc는 코드 생성 단계로 넘어가기 전에 가능한 한 일찍 rmeta 파일을 디스크에 저장합니다. 컴파일러는 JSON 메시지를 보내 빌드 도구에 가능하면 다음 크레이트 빌드를 시작할 수 있음을 알립니다.
크레이트 로딩 시스템은 rlib가 없거나 일부만 기록된 경우 rmeta 파일을 발견하면 그 파일을 사용할 줄 알 만큼 똑똑합니다.
바이너리의 경우에는 링크 단계에서 모든 의존성의 코드 생성이 필요하므로 이러한 파이프라이닝이 불가능합니다. 앞으로는 링크를 별도의 명령으로 분리하여 이 상황을 더욱 개선할 수 있을지도 모릅니다(#64191 참조).
프로파일 기반 최적화
rustc는 프로파일 기반 최적화(PGO)를 지원합니다. 이 장에서는 PGO가 무엇인지, 그리고 rustc에서 PGO 지원이 어떻게 구현되어 있는지 설명합니다.
프로파일 기반 최적화란 무엇입니까?
PGO의 기본 개념은 프로그램의 일반적인 실행에 관한 데이터(예: 어떤 분기를 탈 가능성이 높은지)를 수집한 다음, 이 데이터를 인라이닝, 기계어 코드 배치, 레지스터 할당 등의 최적화에 활용하는 것입니다.
프로그램 실행에 관한 데이터를 수집하는 방법에는 여러 가지가 있습니다. 하나는 프로파일러(예: perf) 안에서 프로그램을 실행하는 것이고, 다른 하나는 계측된 바이너리, 즉 데이터 수집 기능이 내장된 바이너리를 만들어 실행하는 것입니다. 일반적으로 후자가 더 정확한 데이터를 제공합니다.
rustc에서 PGO는 어떻게 구현되어 있습니까?
rustc의 현재 PGO 구현은 전적으로 LLVM에 의존합니다. 실제로 LLVM은 PGO의 여러 형태를 지원합니다.
perf와 같은 외부 프로파일링 도구를 사용하여 프로그램 실행에 관한 데이터를 수집하는 샘플링 기반 PGO.- 코드 커버리지 인프라를 사용하여 프로파일링 정보를 수집하는 GCOV 기반 프로파일링.
- 컴파일러 프런트엔드(예: Clang)가 자신이 생성하는 LLVM IR에 계측 내장 함수를 삽입하는 프런트엔드 기반 계측(단, 1“참고“를 확인하십시오).
- 최적화 패스 중에 LLVM이 직접 계측 내장 함수를 삽입하는 IR 수준 계측.
rustc는 마지막 방식인 IR 수준 계측만 지원합니다. 주된 이유는 이 방식이 거의 전적으로 LLVM에서 구현되어 Rust 쪽에서는 유지 관리가 거의 필요하지 않기 때문입니다. 다행히 이 방식은 가장 현대적인 방식이기도 하며 가장 좋은 결과를 냅니다.
따라서 여기서 다루는 것은 계측 기반 방식입니다. 즉, 프로파일링 데이터는 최적화 대상 프로그램을 특별히 계측한 버전이 생성합니다. 계측 기반 PGO는 컴파일 타임 구성 요소와 런타임 구성 요소, 두 가지로 이루어져 있으며, 이 둘이 어떻게 상호 작용하는지 파악하려면 전체 워크플로를 이해해야 합니다.
전체 워크플로
PGO로 최적화된 프로그램을 생성하려면 다음 네 단계를 거칩니다.
- 계측을 활성화하여 프로그램을 컴파일합니다(예:
rustc -C profile-generate main.rs). - 계측된 프로그램을 실행합니다(예:
./main). 그러면default-<id>.profraw파일이 생성됩니다. - LLVM의
llvm-profdata도구를 사용하여.profraw파일을.profdata파일로 변환합니다. - 이번에는 프로파일링 데이터를 활용하여 프로그램을 다시 컴파일합니다(예:
rustc -C profile-use=merged.profdata main.rs).
컴파일 타임 측면
위 워크플로에서 어느 단계에 있느냐에 따라 컴파일 타임에 서로 다른 두 가지 일이 일어날 수 있습니다:
계측이 포함된 바이너리 생성
앞서 언급했듯이 프로파일링 계측은 LLVM이 추가합니다. rustc는 LLVM PassManager를 생성할 때 적절한 플래그를 설정하여 LLVM이 이를 수행하도록 지시합니다.
// `PMBR` is an `LLVMPassManagerBuilderRef`
unwrap(PMBR)->EnablePGOInstrGen = true;
// Instrumented binaries have a default output path for the `.profraw` file
// hard-coded into them:
unwrap(PMBR)->PGOInstrGen = PGOGenPath;
또한 rustc는 적절한 내보내기 수준으로 표시하여 LLVM 프로파일링 런타임의 일부 심볼이 제거되지 않도록 해야 합니다.
최적화에 프로파일링 데이터를 활용하는 바이너리 컴파일
위에서 설명한 워크플로의 마지막 단계에서는 프로그램을 다시 컴파일하며, 이때 컴파일러는 수집된 프로파일링 데이터를 사용하여 최적화 결정을 내립니다. 여기서도 rustc는 대부분의 작업을 LLVM에 맡기며, 기본적으로 LLVM PassManagerBuilder에 프로파일링 데이터를 찾을 수 있는 위치를 알려 주기만 합니다.
unwrap(PMBR)->PGOInstrUse = PGOUsePath;
나머지는 LLVM이 처리합니다(예: 분기 가중치 설정, 함수에 cold 또는 inlinehint 표시 등).
런타임 측면
계측 기반 접근 방식에는 항상 런타임 구성 요소도 있습니다. 즉, 계측된 프로그램이 준비되면 프로파일링 데이터를 생성하기 위해 그 프로그램을 실행해야 하며, 이 프로파일링 데이터를 수집하고 저장하려면 어느 정도의 인프라가 갖춰져 있어야 합니다.
LLVM의 경우 이러한 런타임 구성 요소는 컴파일러 런타임 라이브러리인 compiler-rt에 구현되어 있으며, 계측된 모든 바이너리에 정적으로 링크됩니다. 이것의 rustc 버전은 library/profiler_builtins에서 찾을 수 있으며, 이는 기본적으로 compiler-rt의 C 코드를 Rust 크레이트로 묶은 것입니다.
profiler_builtins를 빌드하려면 rustc의 bootstrap.toml에 profiler = true를 설정해야 합니다.
PGO 테스트
PGO 워크플로는 여러 번의 컴파일러 호출에 걸쳐 있으므로 대부분의 테스트는 run-make 테스트에서 이루어집니다(관련 테스트는 이름에 pgo가 포함되어 있습니다). 예상되는 일부 계측 산출물이 LLVM IR에 나타나는지 확인하는 코드 생성 테스트도 있습니다.
추가 정보
Clang 문서에는 LLVM의 PGO에 대한 좋은 개요가 있습니다.
-
참고: 이제
rustc는 실험적 옵션인-C instrument-coverage를 통해 프런트엔드 기반 커버리지 계측을 지원하지만, 현재로서는 이 커버리지 결과를 PGO에 활용하려는 시도는 이루어지지 않았습니다. ↩
LLVM 소스 기반 코드 커버리지
rustc는 컴파일 시점에 Rust 라이브러리와 바이너리에 추가 명령어와 데이터를 삽입하여 계측하는 명령줄 옵션(-C instrument-coverage)을 통해 상세한 소스 기반 코드 및 테스트 커버리지 분석을 지원합니다.
커버리지 계측은 (MIR 기반 제어 흐름 분석을 바탕으로) 코드 분기 지점에 LLVM 내장 명령어 llvm.instrprof.increment에 대한 호출을 삽입하며, LLVM은 이를 실행 시 정적 카운터를 증가시키는 명령어로 변환합니다. LLVM 커버리지 계측에는 또한 소스 메타데이터를 인코딩하는 커버리지 맵(Coverage Map)이 필요하며, 이 맵은 카운터 ID를 직접 또는 간접적으로 (시작 및 끝 줄과 열을 포함한) 파일 위치에 매핑합니다.
커버리지 계측 여부와 관계없이 Rust 라이브러리는 계측된 바이너리에 링크될 수 있습니다. 프로그램이 실행되고 정상적으로 종료되면, LLVM 라이브러리는 최종 카운터 값을 파일(default.profraw 또는 환경 변수 LLVM_PROFILE_FILE로 설정한 사용자 지정 파일)에 기록합니다.
개발자는 기존 LLVM 커버리지 분석 도구를 사용하여 .profraw 파일을 (그 파일을 생성한 바이너리에서 얻은) 해당 커버리지 맵과 함께 디코딩하고, 분석을 위한 다양한 보고서를 생성합니다. 예를 들면 다음과 같습니다.
자세한 사용법과 예제는 rustc 책에 문서화되어 있습니다.
권장 bootstrap.toml 설정
커버리지 계측 코드를 작업할 때는 보통 [build]에 profiler = true를 설정하여 프로파일러 런타임을 활성화해야 합니다. 이렇게 하면 컴파일러가 계측된 바이너리를 생성할 수 있고, 전체 커버리지 테스트 스위트를 실행할 수 있습니다.
컴파일러와 LLVM에서 디버그 단언을 활성화하는 것을 권장하지만, 필수는 아닙니다.
# Similar to the "compiler" profile, but also enables debug assertions in LLVM.
# These assertions can detect malformed coverage mappings in some cases.
profile = "codegen"
# IMPORTANT: This tells the build system to build the LLVM profiler runtime.
# Without it, the compiler can't produce coverage-instrumented binaries,
# and many of the coverage tests will be skipped.
build.profiler = true
# Enable debug assertions in the compiler.
rust.debug-assertions = true
Rust 심볼 맹글링
-C instrument-coverage는 일관되고 되돌릴 수 있는 이름 맹글링을 보장하기 위해 Rust 심볼 맹글링 v0을 자동으로 활성화합니다(사용자가 rustc를 호출할 때 -C symbol-mangling-version=v0 옵션을 지정한 것과 같습니다). 이렇게 하면 두 가지 중요한 이점이 있습니다.
- LLVM 커버리지 도구는 소스 코드의 일부 변경을 포함하여 여러 번의 실행에 걸쳐 커버리지를 분석할 수 있으므로, 맹글링된 이름은 여러 컴파일에 걸쳐 일관되어야 합니다.
- LLVM 커버리지 보고서는 함수별로 커버리지를 보고할 수 있으며, 제네릭 함수가 여러 가지 타입 치환으로 호출된 경우에는 각 고유 인스턴스화의 커버리지 횟수를 따로 구분하여 보고하기도 합니다.
LLVM 프로파일러 런타임
커버리지 데이터는 실행 가능한 Rust 프로그램을 실행할 때에만 생성됩니다. rustc는 카운터 값을 .profraw 파일에 기록하는 프로그램 훅(예: exit 훅)을 구현한 LLVM 런타임 코드(compiler-rt, 컴파일러 런타임)를 커버리지 계측된 바이너리에 정적으로 링크합니다.
rustc 소스 트리에서 library/profiler_builtins는 LLVM compiler-rt 코드를 Rust 라이브러리 크레이트로 묶습니다. rustc를 빌드할 때 profiler_builtins는 bootstrap.toml에 build.profiler = true가 설정된 경우에만 포함된다는 점에 유의하십시오.
-C instrument-coverage로 컴파일할 때 CStore::postprocess()는 inject_profiler_runtime()을 호출하여 profiler_builtins를 동적으로 로드합니다.
커버리지 계측 테스트
(tests/coverage 테스트 스위트에 대한 compiletest 문서도 참고하십시오.)
MIR의 커버리지 계측은 mir-opt 테스트인 tests/mir-opt/coverage/instrument_coverage.rs로 검증됩니다.
LLVM IR의 커버리지 계측은 coverage-map 모드의 tests/coverage 테스트 스위트로 검증됩니다. 이 테스트들은 테스트 프로그램을 LLVM IR 어셈블리로 컴파일한 다음, src/tools/coverage-dump 도구를 사용하여 최종 바이너리에 포함될 커버리지 매핑을 추출하고 보기 좋게 출력합니다.
커버리지 계측과 커버리지 보고의 종단 간 테스트는 coverage-run 모드의 tests/coverage 테스트 스위트와 tests/coverage-run-rustdoc 테스트 스위트가 수행합니다. 이 테스트들은 커버리지 계측을 적용하여 테스트 프로그램을 컴파일하고 실행한 다음, LLVM 도구를 사용하여 커버리지 데이터를 사람이 읽을 수 있는 커버리지 보고서로 변환합니다.
coverage-run모드의 테스트에는 암묵적으로//@ needs-profiler-runtime지시문이 적용되므로, 프로파일러 런타임이bootstrap.toml에서 활성화되지 않았다면 건너뜁니다.
마지막으로 tests/codegen-llvm/instrument-coverage/testprog.rs 테스트는 간단한 Rust 프로그램을 -C instrument-coverage로 컴파일하고, 컴파일된 프로그램의 LLVM IR을 커버리지가 활성화된 프로그램에 대해 예상되는 LLVM IR 명령어 및 구조화된 데이터와 비교합니다. 여기에는 커버리지 맵 관련 메타데이터와 런타임 카운터를 증가시키는 LLVM 내장 함수 호출에 대한 다양한 검사가 포함됩니다.
coverage, coverage-run-rustdoc, mir-opt 테스트의 예상 결과는 다음 명령을 실행하여 갱신할 수 있습니다:
./x test coverage --bless
./x test coverage-run-rustdoc --bless
./x test tests/mir-opt --bless
새니타이저 지원
rustc 컴파일러는 다음 새니타이저를 지원합니다:
- AddressSanitizer는 더 빠른 메모리 오류 탐지기입니다. 힙, 스택, 전역 변수에 대한 범위 밖 접근, 해제 후 사용(use after free), 반환 후 사용(use after return), 이중 해제, 잘못된 해제, 메모리 누수를 탐지할 수 있습니다.
- ControlFlowIntegrity는 LLVM 제어 흐름 무결성(CFI)으로, 순방향(forward-edge) 제어 흐름 보호를 제공합니다.
- Hardware-assisted AddressSanitizer는 AddressSanitizer와 비슷하지만 부분적인 하드웨어 지원을 기반으로 하는 도구입니다.
- KernelControlFlowIntegrity는 LLVM 커널 제어 흐름 무결성(KCFI)으로, 운영 체제 커널을 위한 순방향(forward-edge) 제어 흐름 보호를 제공합니다.
- LeakSanitizer는 런타임 메모리 누수 탐지기입니다.
- MemorySanitizer는 초기화되지 않은 읽기를 탐지하는 도구입니다.
- ThreadSanitizer는 빠른 데이터 경쟁 탐지기입니다.
새니타이저는 어떻게 사용합니까?
새니타이저를 활성화하려면 -Z sanitizer=... 옵션을 붙여 컴파일하십시오. 이때 값은 address, cfi, hwaddress, kcfi, leak, memory, thread 중 하나입니다. 새니타이저 사용법에 대한 자세한 내용은 The Unstable Book의 sanitizer 플래그 항목을 참고하십시오.
rustc에서 새니타이저는 어떻게 구현되어 있습니까?
새니타이저 구현은 (CFI를 제외하면) 거의 전적으로 LLVM에 의존합니다. rustc는 LLVM의 컴파일 타임 계측 패스와 런타임 라이브러리를 통합하는 지점입니다. 구현에서 가장 중요한 측면을 짚어 보면 다음과 같습니다:
-
새니타이저 런타임 라이브러리는 compiler-rt(컴파일러 런타임) 프로젝트의 일부이며,
bootstrap.toml에서 활성화하면 지원되는 타깃에서 빌드됩니다:build.sanitizers = true런타임은 타깃 libdir에 배치됩니다.
-
LLVM 코드 생성 중에 계측 대상 함수에는 적절한 LLVM 속성인
SanitizeAddress,SanitizeHWAddress,SanitizeMemory,SanitizeThread중 하나가 표시됩니다. 기본적으로 모든 함수가 계측되지만, 이 동작은#[sanitize(xyz = "on|off|<other>")]로 변경할 수 있습니다. -
계측 수행 여부는 함수 단위로만 결정할 수 있습니다. 이 결정이 함수마다 다른 경우에는 MIR 수준과 LLVM 수준 모두에서 인라이닝을 막아야 할 수도 있습니다.
-
rustc가 생성한 LLVM IR은 새니타이저마다 다른 전용 LLVM 패스로 계측됩니다. 계측 패스는 최적화 패스 이후에 실행됩니다.
-
실행 파일을 만들 때는 새니타이저별 런타임 라이브러리가 링크됩니다. 라이브러리는 타깃 libdir에서 검색합니다. 먼저 재정의된 시스템 루트를 기준으로 검색하고, 그다음 기본 시스템 루트를 기준으로 검색합니다. 기본 시스템 루트로 대체 검색하므로, cargo
-Z build-std나 xargo로 구성한 sysroot 재정의를 사용할 때에도 새니타이저 런타임을 계속 사용할 수 있습니다.
새니타이저 테스트
새니타이저는 tests/codegen-llvm/sanitize*.rs의 코드 생성 테스트와 tests/ui/sanitizer/ 디렉터리의 종단 간 기능 테스트로 검증합니다.
새니타이저 기능을 테스트하려면 새니타이저 런타임(bootstrap.toml에서 build.sanitizer = true일 때 빌드됨)과 해당 새니타이저를 지원하는 타깃이 필요합니다. 주어진 타깃에서 새니타이저가 지원되지 않으면 새니타이저 테스트는 무시됩니다. 이 동작은 compiletest의 needs-sanitizer-* 지시문으로 제어됩니다.
새 타깃에서 새니타이저 활성화하기
LLVM이 이미 지원하는 새 타깃에서 새니타이저를 활성화하려면 다음과 같이 하십시오.
- 타깃 정의의
supported_sanitizers목록에 해당 새니타이저를 포함하십시오. 이제rustc --target .. -Zsanitizer=..가 해당 새니타이저를 지원되는 것으로 인식해야 합니다. - 타깃용 런타임을 빌드하여 libdir에 포함하십시오.
- 여러분의 타깃이 이제 해당 새니타이저를 지원한다는 것을 compiletest에 알려 주십시오. 이제
needs-sanitizer-*로 표시된 테스트가 해당 타깃에서 실행되어야 합니다. - 확인하려면
./x test --force-rerun tests/ui/sanitize/테스트를 실행하십시오. - CI 구성에서 –enable-sanitizers를 설정하여 릴리스 과정의 일부로 새니타이저 런타임을 빌드하고 배포하십시오.
추가 정보
배경 주제
이 절에서는 이 가이드에 등장하는 여러 일반적인 컴파일러 용어를 다룹니다. 일반적인 정의를 제시하면서 Rust에 특화된 맥락도 함께 제공하고자 합니다.
제어 흐름 그래프란 무엇입니까?
제어 흐름 그래프(CFG)는 컴파일러에서 흔히 쓰이는 용어입니다. 순서도를 사용해 본 적이 있다면 제어 흐름 그래프의 개념이 꽤 익숙하게 느껴질 것입니다. 제어 흐름 그래프는 프로그램의 밑바탕에 있는 제어 흐름을 명확하게 드러내는 표현 방식입니다.
제어 흐름 그래프는 간선으로 연결된 기본 블록의 집합으로 구성됩니다. 기본 블록의 핵심 개념은 “함께” 실행되는 문장의 집합이라는 점입니다. 즉, 기본 블록으로 분기할 때마다 항상 첫 번째 문장에서 시작하여 나머지 문장을 모두 실행합니다. 블록의 끝에서만 두 곳 이상으로 분기할 가능성이 있습니다(MIR에서는 이 마지막 문장을 종결자라고 부릅니다):
bb0: {
statement0;
statement1;
statement2;
...
terminator;
}
Rust에서 익숙하게 사용하는 많은 표현식은 여러 개의 기본 블록으로 컴파일됩니다. 예를 들어 다음 if 문을 살펴보십시오:
a = 1;
if some_variable {
b = 1;
} else {
c = 1;
}
d = 1;
이 코드는 MIR에서 네 개의 기본 블록으로 컴파일됩니다. 텍스트 형태로는 다음과 같습니다:
BB0: {
a = 1;
if some_variable {
goto BB1;
} else {
goto BB2;
}
}
BB1: {
b = 1;
goto BB3;
}
BB2: {
c = 1;
goto BB3;
}
BB3: {
d = 1;
...
}
그래프 형태로는 다음과 같습니다:
BB0
+--------------------+
| a = 1; |
+--------------------+
/ \
if some_variable else
/ \
BB1 / \ BB2
+-----------+ +-----------+
| b = 1; | | c = 1; |
+-----------+ +-----------+
\ /
\ /
\ BB3 /
+----------+
| d = 1; |
| ... |
+----------+
제어 흐름 그래프를 사용하면 루프는 단순히 그래프의 순환으로 나타나고, break 키워드는 그 순환을 빠져나가는 경로로 변환됩니다.
데이터 흐름 분석이란 무엇입니까?
Anders Møller와 Michael I. Schwartzbach가 쓴 Static Program Analysis는 훌륭한 자료입니다!
데이터 흐름 분석은 많은 컴파일러에서 흔히 사용되는 정적 분석의 한 종류입니다. 이는 특정한 분석이라기보다는 일반적인 기법을 가리킵니다.
기본 아이디어는 제어 흐름 그래프(CFG)를 순회하면서 어떤 값이 무엇이 될 수 있는지를 추적하는 것입니다. 순회가 끝나면 어떤 주장이 참인지, 또는 반드시 참은 아닌지를 보일 수 있습니다(예: “이 변수는 반드시 초기화되어야 함”). MIR은 이미 CFG이므로 rustc는 주로 MIR에 대해 데이터 흐름 분석을 수행합니다.
예를 들어 다음 코드 조각에서 x가 사용되기 전에 초기화되는지 확인하고 싶다고 가정해 봅시다:
fn foo() {
let mut x;
if some_cond {
x = 1;
}
dbg!(x);
}
이 코드의 CFG는 다음과 같을 수 있습니다:
+------+
| Init | (A)
+------+
| \
| if some_cond
else \ +-------+
| \| x = 1 | (B)
| +-------+
| /
+---------+
| dbg!(x) | (C)
+---------+
데이터 흐름 분석은 다음과 같이 수행할 수 있습니다. 먼저 x가 초기화되었음을 알고 있는지를 나타내는 플래그 init에서 시작합니다. CFG를 순회하면서 이 플래그를 갱신합니다. 마지막에 그 값을 확인할 수 있습니다.
먼저 블록 (A)에서는 변수 x가 선언되었지만 초기화되지 않았으므로 init = false입니다. 블록 (B)에서는 값을 초기화하므로 x가 초기화되었음을 알 수 있습니다. 따라서 (B)의 끝에서는 init = true입니다.
흥미로운 부분은 블록 (C)입니다. 들어오는 간선이 두 개, 즉 (A)에서 오는 간선과 (B)에서 오는 간선이 있으며, 이는 some_cond가 참인지 아닌지에 대응한다는 점에 주목하십시오. 하지만 우리는 그것을 알 수 없습니다! some_cond가 항상 참이어서 x가 실제로는 항상 초기화되는 경우일 수도 있습니다. 또한 some_cond가 무작위적인 무언가(예: 시간)에 의존하여 x가 초기화되지 않을 수도 있는 경우일 수도 있습니다. 일반적으로 우리는 이를 정적으로 알 수 없습니다(라이스의 정리 때문입니다). 그렇다면 블록 (C)에서 init의 값은 무엇이어야 합니까?
일반적으로 데이터 흐름 분석에서 어떤 블록에 부모가 여러 개 있으면(예시의 (C)처럼), 그 블록의 데이터 흐름 값은 모든 부모(그리고 물론 (C)에서 일어나는 일)에 대한 어떤 함수가 됩니다. 어떤 함수를 사용할지는 수행하는 분석에 따라 달라집니다.
이 경우 우리는 x가 사용 전에 반드시 초기화되어야 함을 확정적으로 증명할 수 있기를 원합니다. 이 때문에 우리는 보수적으로 접근하여 some_cond가 때로는 거짓일 수 있다고 가정해야 합니다. 따라서 우리의 “병합 함수“는 “and“입니다. 즉, (A)와 (B) 모두에서 init = true이면(또는 (C)에서 x가 초기화되면) (C)에서 init = true입니다. 하지만 실제로는 그렇지 않습니다. 특히 (A)에서 init = false이고, (C)에서 x는 초기화되지 않습니다. 따라서 (C)에서 init = false이며, “x가 사용 전에 초기화되지 않았을 수 있습니다“라는 오류를 보고할 수 있습니다.
데이터 흐름 분석에 대해서는 분명히 훨씬 더 많은 이야기를 할 수 있습니다. 이 주제에 관해서는 많은 이론을 포함한 방대한 연구 문헌이 있습니다. 여기서는 전방 분석만 다루었지만, 후방 데이터 흐름 분석도 유용합니다. 예를 들어, 블록 (A)에서 시작하여 앞으로 나아가는 대신, x의 사용 지점에서 시작하여 뒤로 거슬러 올라가며 초기화 지점을 찾으려 할 수도 있었습니다.
“전칭 양화“란 무엇입니까? “존재 양화“는 무엇입니까?
수학에서 술어는 전칭 양화되거나 존재 양화될 수 있습니다:
- 전칭 양화:
- 가능한 모든 입력에 대해 참이면 술어가 성립합니다.
- 전통적인 표기법: ∀x: P(x). “모든 x에 대해 P(x)가 성립한다“라고 읽습니다.
- 존재 양화:
- 참이 되는 입력이 하나라도 있으면 술어가 성립합니다. 즉, 그러한 입력이 단 하나만 있으면 됩니다.
- 전통적인 표기법: ∃x: P(x). “P(x)가 성립하는 x가 존재한다“라고 읽습니다.
Rust에서는 타입 검사와 트레이트 해결에서 이러한 개념이 등장합니다. 예를 들면 다음과 같습니다.
fn foo<T>()
이 함수는 모든 타입 T에 대해 함수가 올바르게 타입 지정되어 있다고 주장합니다: ∀ T: well_typed(foo).
또 다른 예입니다.
fn foo<'a>(_: &'a usize)
이 함수는 (호출자가 결정하는) 임의의 라이프타임 'a에 대해 올바르게 타입 지정되어 있다고 주장합니다: ∀ 'a: well_typed(foo).
또 다른 예입니다.
fn foo<F>()
where for<'a> F: Fn(&'a u8)
이 함수는 모든 라이프타임 'a에 대해 F: Fn(&'a u8)을 만족하는 모든 타입 F에 대해 올바르게 타입 지정되어 있다고 주장합니다: ∀ F: ∀ 'a: (F: Fn(&'a u8)) => well_typed(foo).
예를 하나 더 들겠습니다.
fn foo(_: dyn Debug)
이 함수는 Debug를 구현하면서 함수가 올바르게 타입 지정되도록 하는 어떤 타입 T가 존재한다고 주장합니다: ∃ T: (T: Debug) and well_typed(foo).
de Bruijn 인덱스란 무엇입니까?
de Bruijn 인덱스는 어떤 변수가 어떤 바인더에 바인딩되어 있는지를 정수만으로 표현하는 방법입니다. 원래는 람다 대수의 평가에 사용하기 위해 고안되었습니다(자세한 내용은 이 위키백과 문서를 참고하십시오). rustc에서는 제네릭 타입을 표현하기 위해 de Bruijn 인덱스를 사용합니다.
다음은 클로저에 de Bruijn 인덱스를 어떻게 사용할 수 있는지 보여 주는 기본적인 예입니다(다만 실제로 rustc에서는 이렇게 하지 않습니다!):
|x| {
f(x) // de Bruijn index of `x` is 1 because `x` is bound 1 level up
|y| {
g(x, y) // index of `x` is 2 because it is bound 2 levels up
// index of `y` is 1 because it is bound 1 level up
}
}
공변성과 반공변성이란 무엇입니까?
Rust Nomicon의 서브타이핑 장을 확인하십시오.
타입 검사기가 변성을 처리하는 방식에 대한 자세한 내용은 이 가이드의 변성 장을 참고하십시오.
“자유 영역” 또는 “자유 변수“란 무엇입니까? “바운드 영역“은 무엇입니까?
우리에게 가장 익숙한 개념인 프로그램 변수를 가지고 자유와 바인딩의 개념을 설명해 봅시다.
- 클로저를 생성하는 다음 표현식을 생각해 보십시오:
|a, b| a + b. 여기서a + b에 있는a와b는 클로저가 호출될 때 전달받을 인자를 가리킵니다. 이때a와b는 클로저에 바인딩되어 있다고 하며, 클로저 시그니처|a, b|는 이름a와b에 대한 바인더라고 합니다(그 안에서a나b를 참조하는 것은 모두 이 시그니처가 도입하는 변수를 가리키기 때문입니다). - 다음 표현식을 생각해 보십시오:
a + b. 이 표현식에서a와b는 표현식 바깥에서 정의된 지역 변수를 가리킵니다. 이러한 변수는 표현식에 자유롭게 나타난다고 합니다(즉, 이 변수들은 바인딩된(묶인) 것이 아니라 자유 변수입니다).
정리하면 다음과 같습니다: 어떤 변수가 표현식/문/기타 등의 바깥에서 정의된 무언가를 가리키면, 그 변수는 해당 표현식/문/기타 등에 “자유롭게 나타난다“고 합니다. 마찬가지로, 표현식의 “자유 변수“라는 말도 쓸 수 있는데, 이는 단순히 “자유롭게 나타나는” 변수들의 집합을 의미합니다.
그렇다면 이것이 영역(region)과 무슨 관계가 있습니까? 이와 유사한 개념을 타입과 영역에도 적용할 수 있습니다. 예를 들어, &'a u32 타입에서 'a는 자유 변수로 나타납니다. 하지만 for<'a> fn(&'a u32) 타입에서는 그렇지 않습니다.
컴파일러에 관한 추가 자료
공식 Discord에서 추천해 주신
mem,scottmcm,Levi께 감사드리며, 더 많은 추천이 담긴 Graydon Hoare의 트위터 스레드 링크를 올려 주신tinaun께도 감사드립니다!기타 자료: https://gcc.gnu.org/wiki/ListOfCompilerBooks
다른 제안이 있으시면 언제든지 이슈나 PR을 열어 주십시오.
도서
- Types and Programming Languages
- Programming Language Pragmatics
- Practical Foundations for Programming Languages
- Compilers: Principles, Techniques, and Tools, 2nd Edition
- Garbage Collection: Algorithms for Automatic Dynamic Memory Management
- 링커와 로더(Linkers and Loaders) (무료 버전도 있지만, 저희가 링크했던 버전은 현재 오프라인 상태인 것으로 보입니다.)
- 고급 컴파일러 설계 및 구현(Advanced Compiler Design and Implementation)
- 최적화 컴파일러 구축(Building an Optimizing Compiler)
- Crafting Interpreters
강좌
위키
기타 논문 및 블로그 글
용어집
| 용어 | 의미 |
|---|---|
| 1-ZST | 1로 정렬된 크기가 0인 타입입니다. 크기가 0이고 정렬이 1인 타입입니다. |
| abby | 바인더에 대한 가정(assumptions on binders)의 약어입니다. a-bi라고 쓰기도 하지만, 이는 ABI와 매우 비슷합니다. |
| 아레나, 아레나 할당 | 아레나는 다른 메모리 할당이 이루어지는 큰 메모리 버퍼입니다. 이러한 방식의 할당을 아레나 할당이라고 합니다. 자세한 내용은 이 장을 참조하십시오. |
| AFIDT | dyn Trait 안의 async 함수의 약어입니다. AFIT도 참조하십시오. |
| AFIT | 트레이트 안의 async 함수(async function in trait)의 약어입니다. 이는 RPITIT로 디슈가링됩니다. |
| AST | 파서가 생성하는 추상 구문 트리(IR의 일종)로, 표면 구문, 즉 사용자가 작성한 구문을 매우 가깝게 반영합니다. |
| APIT | 인자 위치 impl Trait의 약어입니다. (존재적인 것과 대비되는) 보편적 impl Trait 또는 익명 타입 매개변수라고도 합니다. (레퍼런스를 참조하십시오). |
| ATPIT | 연관 타입 위치 impl Trait의 약어입니다. ITIAT라고도 합니다. |
| 바인더 | 바인더는 변수나 타입이 선언되는 위치입니다. 예를 들어 fn foo<T>(..)에서 <T>는 타입 매개변수 T의 바인더이고, for<'a>는 라이프타임 매개변수 'a의 바인더이며, |a| …는 매개변수 a의 바인더입니다. 자세한 내용은 배경 장을 참조하십시오. |
| 본문 | “실행 가능한 코드“를 포함하는 함수 또는 상수의 정의입니다. |
BodyId | 크레이트 안의 특정 본문(body)을 가리키는 식별자입니다. 자세한 내용은 HIR 장을 참고하십시오. |
| 바운드 변수 | 바운드 변수는 (일반적인 의미에서) 표현식이나 항 내부에서 선언된 변수입니다. 예를 들어, 변수 a는 클로저 표현식 |a| a * 2 안에서 바인딩되고, 라이프타임 변수 'a는 타입 표현식 for<'a> fn(&'a str) -> bool 안에서 바인딩됩니다. 자세한 내용은 배경 지식 장을 참고하십시오. |
| 코드 생성(codegen) | 코드 생성(code generation)의 줄임말입니다. MIR을 LLVM IR로 변환하는 코드입니다. |
| 코드 생성 단위(codegen unit) | LLVM IR을 생성할 때 Rust 코드를 여러 개의 코드 생성 단위(CGU로 줄여 부르기도 합니다)로 묶습니다. 각 단위는 LLVM이 서로 독립적으로 처리하므로 병렬 처리가 가능합니다. 코드 생성 단위는 증분 재사용의 단위이기도 합니다. (자세히 보기) |
| 완전성(completeness) | 타입 이론의 전문 용어로, 타입 안전한 모든 프로그램이 타입 검사도 통과한다는 뜻입니다. 건전성과 완전성을 모두 갖추기는 매우 어려우며, 보통은 건전성이 더 중요합니다. (“건전성(soundness)” 항목을 참고하십시오.) |
| 제어 흐름 그래프(control-flow graph, CFG) | 프로그램의 제어 흐름을 나타내는 표현입니다. 자세한 내용은 배경 지식 장을 참고하십시오. |
| CTA | 검사되는 타입 별칭(checked type alias)으로, 중간 ty IR에서 별칭으로 “제대로” 표현되는 타입 별칭입니다. (검사되지 않는) 타입 별칭은 HIR ty 로워링 중에 참조 위치가 실제 별칭 대상 타입(인스턴스화한 뒤의 타입 별칭 우변)으로 펼쳐지는 반면, CTA의 참조 위치는 AliasTy로 로워링됩니다. |
| CTFE | 컴파일 타임 함수 평가(compile-time function evaluation)의 줄임말로, 컴파일러가 const fn을 컴파일 타임에 평가하는 기능을 말합니다. 이는 컴파일러의 상수 평가 시스템의 일부입니다. (자세히 보기) |
cx | 컨텍스트(context)의 줄임말로 cx를 자주 사용합니다. tcx, infcx 등도 참고하십시오. |
ctxt | 컨텍스트(context)의 줄임말로 ctxt도 사용합니다. 예를 들면 TyCtxt가 있습니다. cx나 tcx도 참고하십시오. |
| DAG | 방향성 비순환 그래프(directed acyclic graph)는 컴파일 중에 쿼리 간의 의존성을 추적하는 데 사용됩니다. (자세히 보기) |
| 데이터 흐름 분석(data-flow analysis) | 프로그램의 제어 흐름상 각 지점에서 어떤 속성이 참인지 알아내는 정적 분석입니다. 자세한 내용은 배경 지식 장을 참고하십시오. |
| 드 브라윈 인덱스(de Bruijn index) | 변수가 어느 바인더에 의해 바인딩되는지를 정수만으로 나타내는 기법입니다. 변수 이름을 바꾸어도 변하지 않는다는 장점이 있습니다. (자세히 보기) |
DefId | 정의를 식별하는 인덱스입니다(rustc_middle/src/hir/def_id.rs 참고). DefPath를 고유하게 식별합니다. 자세한 내용은 HIR 장을 참고하십시오. |
| 판별값 | 열거형 배리언트나 제너레이터 상태가 “활성” 상태임을 나타내기 위해 그와 연관된 기저 값입니다(단, “배리언트 인덱스”와 혼동해서는 안 됩니다). 런타임에 활성 배리언트의 판별값은 태그에 인코딩됩니다. |
| 이중 포인터(double pointer) | 추가 메타데이터를 가진 포인터입니다. 자세한 내용은 팻 포인터를 참고하십시오. |
| 드롭 글루(drop glue) | (내부) 데이터 타입의 소멸자(Drop) 호출을 처리하는, 컴파일러가 생성한 명령어입니다. |
| DST | 동적 크기 타입(dynamically-sized type)의 줄임말로, 컴파일러가 메모리상의 크기를 정적으로 알 수 없는 타입입니다(예: str 또는 [u8]). 이러한 타입은 Sized를 구현하지 않으며 스택에 할당될 수 없습니다. 구조체의 마지막 필드로만 올 수 있습니다. 포인터 뒤에서만 사용할 수 있습니다(예: &str 또는 &[u8]). |
| 이른 바인딩 라이프타임(early-bound lifetime) | 정의 위치에서 치환되는 라이프타임/영역(region)입니다. 아이템의 Generics에서 바인딩되며 GenericArgs를 사용하여 치환/인스턴스화됩니다. 늦은 바인딩 라이프타임과 대조됩니다. (자세히 보기) |
| 이펙트(effects) | 현재로서는 const 트레이트와 ~const 바운드만을 의미합니다. (자세히 보기) |
| 빈 타입(empty type) | 거주 불가능 타입을 참고하십시오. |
| 팻 포인터(fat pointer) | 어떤 값의 주소와 함께 그 값을 사용하는 데 필요한 추가 정보를 담고 있는 두 워드 크기의 값입니다. Rust에는 두 종류의 팻 포인터(fat pointer)가 있습니다. 바로 슬라이스에 대한 참조와 트레이트 객체입니다. 슬라이스에 대한 참조는 슬라이스의 시작 주소와 길이를 담고 있습니다. 트레이트 객체는 값의 주소와, 그 값에 맞는 트레이트 구현을 가리키는 포인터를 담고 있습니다. “팻 포인터“는 “와이드 포인터(wide pointer)” 또는 “더블 포인터(double pointer)“라고도 합니다. |
| 자유 변수(free variable) | 자유 변수란 (일반적인 의미에서) 표현식이나 항 안에서 바인딩되지 않은 변수를 말합니다. 자세한 내용은 배경 지식 장을 참고하십시오 |
| GAC | 제네릭 연관 상수(generic associated constant)로, (자체) 제네릭 매개변수나 where 절을 가진 연관 상수입니다. generic_const_items(GCI) 기능의 일부입니다. |
| GAT | 제네릭 연관 타입(generic associated type)으로, (자체) 제네릭 매개변수나 where 절을 가진 연관 타입입니다. RFC 1598에서 도입되었습니다. |
| 제네릭(generics) | 아이템에 정의된 제네릭 매개변수의 목록입니다. 제네릭 매개변수에는 세 가지 종류가 있습니다: 타입, 라이프타임, 상수 매개변수입니다. |
| HIR | AST를 로워링(lowering)/디슈가링(desugaring)하여 만들어지는 고수준 IR입니다. (자세히 보기) |
HirId | def-id와 “정의 내 오프셋(intra-definition offset)“을 결합하여 HIR의 특정 노드를 식별합니다. 자세한 내용은 HIR 장을 참고하십시오. |
| IAC | (대개 타입 수준의) 고유 연관 상수(inherent associated constant)로, 고유 impl impl Type { … } 안에 있는 연관 상수입니다. min_generic_const_items(mGCA, MGCA) 기능과 관련하여 자주 언급됩니다. IGAC는 고유 GAC입니다. |
| IAT | 고유 연관 타입(inherent associated type)으로, 고유 impl impl Type { … } 안에 정의된 연관 타입입니다. IGAT는 고유 GAT, 즉 제네릭 IAT입니다. |
| ICE | 내부 컴파일러 오류(internal compiler error)의 약자로, 컴파일러가 비정상 종료되는 경우를 말합니다. |
| ICH | 증분 컴파일 해시(incremental compilation hash)의 줄임말로, HIR이나 크레이트 메타데이터 같은 것의 핑거프린트로 사용되어 변경 사항이 있는지 확인하는 데 쓰입니다. 이는 증분 컴파일에서 크레이트의 일부가 변경되어 다시 컴파일해야 하는지 확인하는 데 유용합니다. |
infcx | 타입 추론 컨텍스트(InferCtxt)입니다. (rustc_middle::infer를 참고하십시오) |
| 추론 변수, infer var | 타입, 영역, 상수 추론을 수행할 때 추론 변수(inference variable)는 추론하려는 대상을 나타내는 특수한 타입/영역의 일종입니다. 대수학의 X를 떠올려 보십시오. 예를 들어 프로그램에서 어떤 변수의 타입을 추론하려는 경우, 그 알려지지 않은 타입을 나타내기 위해 추론 변수를 생성합니다. |
| intern | 인터닝(interning)은 메모리 사용량과 할당 횟수를 줄이기 위해 문자열처럼 자주 사용되는 특정 상수 데이터를 저장해 두고, 데이터 자체 대신 식별자(예: Symbol)로 그 데이터를 참조하는 것을 말합니다. 자세한 내용은 이 장을 참고하십시오. |
| interpreter | 컴파일 시점에 MIR 코드를 실행하는 상수 평가의 핵심입니다. (자세히 보기) |
| intrinsic | 인트린식(intrinsic)은 컴파일러 자체에 구현되어 있지만 사용자에게 (대개 불안정한 형태로) 노출되는 특수 함수입니다. 인트린식은 마법 같고 위험한 일을 합니다. (std::intrinsics를 참고하십시오) |
| IR | 컴파일러 분야의 일반 용어인 중간 표현(intermediate representation)의 약어입니다. 컴파일 과정에서 코드는 원시 소스(ASCII 텍스트)에서 다양한 IR로 변환됩니다. Rust에서는 주로 HIR, MIR, LLVM IR이 이에 해당합니다. 각 IR은 특정 종류의 계산에 적합합니다. 예를 들어 MIR은 빌림 검사기에 적합하고, LLVM IR은 LLVM이 이를 입력으로 받으므로 코드 생성에 적합합니다. |
| IRLO, irlo | internals.rust-lang.org의 약어로 쓰이기도 합니다. |
| item | static, const, use 문, 모듈, 구조체 등과 같은 언어의 “정의“의 일종입니다. 구체적으로는 Item 타입에 해당합니다. |
| 항목 시그니처 | 아이템(예: 구조체, 함수)의 타입 시그니처/어노테이션/지정입니다. 본문(body) 안의 타입과 달리 추론을 수행하지 않는 곳이기 때문에 타입 추론과 관련하여 자주 언급됩니다. |
| ITIAT | 연관 타입 내 impl Trait(impl Trait in associated type)의 약어입니다. ATPIT라고도 합니다. |
| 언어 항목 | 언어 자체에 내재된 개념을 나타내는 아이템으로, Sync나 Send 같은 특수한 내장 트레이트, Add처럼 연산을 나타내는 트레이트, 또는 컴파일러가 호출하는 함수 등이 있습니다. (자세히 보기) |
| 후기 바인딩 라이프타임 | 호출 지점에서 치환되는 라이프타임 또는 영역입니다. HRTB에서 바인딩되며, liberate_late_bound_regions와 같은 컴파일러 내 특정 함수에 의해 치환됩니다. 조기 바인딩 라이프타임과 대비됩니다. (자세히 보기) |
| 로컬 크레이트 | 현재 컴파일 중인 크레이트입니다. 이는 로컬 크레이트의 의존성을 가리키는 “업스트림 크레이트“와 대비되는 개념입니다. |
| 로워링 | 상위 수준의 IR을 하위 수준의 IR로 변환하는 작업입니다. 예를 들어, AST 로워링(AST에서 HIR로) 또는 HIR ty 로워링(HIR에서 middle ty IR로)이 있습니다. |
| LTA 👎 | lazy type alias(지연 타입 별칭)의 약자입니다. CTA로 이름이 변경되었습니다. |
| LTO | link-time optimizations(링크 타임 최적화)의 약자로, 최종 바이너리가 링크되기 직전에 수행되는, LLVM이 제공하는 최적화의 집합입니다. 예를 들어 최종 프로그램에서 전혀 사용되지 않는 함수를 제거하는 것과 같은 최적화가 여기에 포함됩니다. ThinLTO는 확장성과 효율성을 조금 더 높이는 것을 목표로 하는 LTO의 변형이지만, 일부 최적화를 포기할 수도 있습니다. Rust 저장소에서 “FatLTO“에 관한 이슈를 볼 수도 있는데, 이는 Thin이 아닌 LTO에 붙은 애칭입니다. LLVM 문서는 여기와 여기를 참고하십시오. |
| LLVM | (사실 약어가 아닙니다 :P) 오픈 소스 컴파일러 백엔드입니다. LLVM IR을 입력받아 네이티브 바이너리를 출력합니다. 그러면 다양한 언어(예: Rust)는 LLVM IR을 출력하는 컴파일러 프런트엔드를 구현하고, LLVM을 사용하여 LLVM이 지원하는 모든 플랫폼용으로 컴파일할 수 있습니다. |
| 메모이제이션 | (순수) 계산(예: 순수 함수 호출)의 결과를 저장하여 나중에 같은 계산을 반복하지 않도록 하는 과정입니다. 이는 일반적으로 실행 속도와 메모리 사용량 사이의 트레이드오프입니다. |
| middle ty IR | 타입 검사기와 트레이트 솔버가 사용하는, rustc_middle::ty 모듈에 정의된 타입들의 모음입니다. 이 타입들은 본질적으로 하나의 IR을 이룹니다. |
| MIR | 타입 검사 이후에 생성되어 빌림 검사기(borrowck)와 코드 생성에 사용되는 중간 수준 IR입니다. (자세히 보기) |
| Miri | (unsafe) Rust 코드에서 정의되지 않은 동작(Undefined Behavior)을 탐지하는 도구입니다. (자세히 보기) |
| 단형화 | 타입과 함수의 제네릭 구현을 구체적인 타입으로 인스턴스화하는 과정입니다. 예를 들어 코드에는 Vec<T>가 있을 수 있지만, 최종 실행 파일에는 프로그램에서 사용되는 구체적인 타입마다 Vec 코드의 사본이 하나씩 존재하게 됩니다(예: Vec<usize>용 사본, Vec<MyStruct>용 사본 등). |
| normalize | 더 정규적인 형태로 변환하는 것을 가리키는 일반적인 용어이지만, rustc에서는 보통 연관 타입 정규화를 가리킵니다. |
| newtype | 다른 타입을 감싸는 래퍼입니다(예: struct Foo(T)는 T에 대한 “newtype“입니다). Rust에서는 인덱스에 더 강한 타입을 부여하기 위해 흔히 사용됩니다. |
| niche | 어떤 타입의 유효하지 않은 비트 패턴 중 레이아웃 최적화에 사용될 수 있는 것입니다. 일부 타입은 특정 비트 패턴을 가질 수 없습니다. 예를 들어 NonZero* 정수나 참조 &T는 0 비트열로 표현될 수 없습니다. 따라서 컴파일러는 유효하지 않은 “niche 값“을 활용하여 레이아웃 최적화를 수행할 수 있습니다. 이를 활용하는 예로 Option과 유사한 열거형에서의 판별값 생략이 있으며, 이를 통해 별도의 필드 없이 타입의 니치를 enum의 “태그”로 사용할 수 있습니다. |
| NLL | 비어휘적 라이프타임(non-lexical lifetimes)의 약어로, Rust의 빌림 시스템이 제어 흐름 그래프를 기반으로 동작하도록 하는 확장입니다. |
노드 id 또는 NodeId | AST나 HIR에서 특정 노드를 식별하는 인덱스로, 점차 사용이 중단되고 HirId로 대체되고 있습니다. 자세한 내용은 HIR 장을 참고하십시오. |
| obligation | 트레이트 시스템이 증명해야 하는 것입니다. (자세히 보기) |
| placeholder | 참고: skolemization은 placeholder로 대체되어 더 이상 사용되지 않습니다 “for-all” 타입(예: for<'a> fn(&'a u32))과 관련된 서브타이핑을 처리하고 고차 트레이트 바운드(예: for<'a> T: Trait<'a>)를 해결하는 방법입니다. 자세한 내용은 플레이스홀더와 유니버스에 관한 장을 참조하십시오. |
| point | NLL 분석에서 MIR 내의 특정 위치를 가리키는 데 사용되며, 일반적으로 제어 흐름 그래프의 노드를 가리킵니다. |
| 프로젝션 | “상대 경로“를 가리키는 일반적인 용어입니다. 예를 들어 x.f는 “필드 프로젝션“이고, T::Item은 “연관 타입 프로젝션”입니다. |
| promoted constants | 함수에서 추출되어 정적 스코프로 끌어올려진 상수입니다. 자세한 내용은 이 절을 참고하십시오. |
| 프로바이더 | 쿼리를 실행하는 함수입니다. (자세히 보기) |
| quantified | 수학이나 논리학에서 존재 양화와 전칭 양화는 “참이 되는 타입 T가 존재하는가?” 또는 “이것이 모든 타입 T에 대해 참인가?“와 같은 질문을 할 때 사용됩니다. 자세한 내용은 배경 지식 장을 참고하십시오. |
| 쿼리 | 컴파일 중에 수행되는 하위 계산입니다. 쿼리 결과는 현재 세션에 캐시하거나, 증분 컴파일을 위해 디스크에 캐시할 수 있습니다. (자세히 보기) |
| 복구(recovery) | 복구는 파싱 중에 잘못된 구문(예: 누락된 쉼표)을 처리하고 AST 파싱을 계속 진행하는 것을 말합니다. 이를 통해 사용자에게 불필요한 오류가 표시되는 것을 방지합니다(예: 구조체 정의에 오류가 있을 때 ‘필드 누락’ 오류가 표시되는 경우). |
| 영역(region) | 문헌과 빌림 검사기에서 자주 사용되는, “라이프타임“을 가리키는 또 다른 용어입니다. |
| 립(rib) | 이름 해석기에서 이름에 대한 단일 스코프를 추적하는 자료 구조입니다. (자세히 보기) |
| RPIT | 반환 위치 impl Trait(return-position impl Trait)의 약어입니다. TAIT나 ATPIT처럼 (전칭이 아닌) 존재 impl Trait입니다. (레퍼런스 참고). |
| RPITIT | 트레이트 내 반환 위치 impl Trait(return-position impl Trait in trait)의 약어입니다. RPIT과 달리, 이것은 제네릭 연관 타입(GAT)으로 디슈가링됩니다. RFC 3425에서 도입되었습니다. (자세히 보기) |
| rustbuild 👎 | 부트스트랩 중 Rust로 작성된 부분을 가리키는 더 이상 사용되지 않는 용어입니다. |
| 조사 대상(scrutinee) | 조사 대상(scrutinee)은 match 표현식 및 이와 유사한 패턴 매칭 구문에서 매칭의 대상이 되는 표현식입니다. 예를 들어 match x { A => 1, B => 2 }에서는 표현식 x가 조사 대상입니다. |
sess | 컴파일 전반에 걸쳐 사용되는 전역 데이터를 저장하는 컴파일러 세션입니다. |
| 사이드 테이블(side table) | AST와 HIR은 한 번 생성되면 변경할 수 없으므로, 이들에 대한 추가 정보를 특정 노드의 id로 인덱싱되는 해시 테이블 형태로 따로 보관하는 경우가 많습니다. |
| 시길(sigil) | 키워드와 비슷하지만 영숫자가 아닌 토큰으로만 구성된 것입니다. 예를 들어 &는 참조를 나타내는 시길입니다. |
| 건전성(soundness) | 타입 이론의 전문 용어입니다. 대략적으로 말하면, 타입 시스템이 건전(sound)하다면 타입 검사를 통과한 프로그램은 타입 안전합니다. 즉, (안전한 Rust에서는) 값을 잘못된 타입의 변수에 강제로 넣을 수 없습니다. (“완전성(completeness)” 항목을 참고하십시오.) |
| span | 사용자 소스 코드 내의 위치로, 주로 오류 보고에 사용됩니다. 이는 파일 이름/줄 번호/열 튜플의 강화판과 같습니다: 시작/끝 지점을 담고 있으며, 매크로 확장과 컴파일러 디슈가링도 추적합니다. 그러면서도 이 모든 것이 몇 바이트 안에 압축되어 있습니다(실제로는 테이블에 대한 인덱스입니다). 자세한 내용은 Span 데이터 타입을 참고하십시오. |
| subst 👎 | substs를 제공하여 타입, 상수 표현식 등의 내부에 있는 제네릭 매개변수를 구체적인 제네릭 인자로 치환(substituting)하는 행위입니다. 오늘날 컴파일러에서는 인스턴스화(instantiating)라고 부릅니다. |
| substs 👎 | 주어진 제네릭 항목에 대한 치환(substitutions)입니다(예: HashMap<i32, u32>의 i32, u32). 오늘날 컴파일러에서는 제네릭 인자(generic arguments) 목록이라고 부릅니다(단, 엄밀히 말하면 두 개념은 서로 다르므로 관련 문헌을 참고하십시오). |
| sysroot | 컴파일러가 런타임에 로드하는 빌드 산출물을 위한 디렉터리입니다. (자세히 보기) |
| tag | 열거형/제너레이터의 “태그“는 활성 변형/상태의 판별값(discriminant)을 인코딩합니다. 태그는 “직접” 방식(판별값을 필드에 그대로 저장)이거나 “니치(niche)”를 사용할 수 있습니다. |
| TAIT | 타입 별칭 impl Trait(type-alias impl Trait)의 줄임말입니다. RFC 2515에서 도입되었습니다. |
tcx | 컴파일러의 주요 자료 구조인 “타이핑 컨텍스트”(TyCtxt)를 가리키는 표준 변수 이름입니다. (자세히 보기) |
'tcx | TyCtxt가 사용하는 할당 아레나의 라이프타임입니다. 컴파일 세션 중에 인터닝되는 대부분의 데이터는 이 라이프타임을 사용하며, 'hir 라이프타임을 사용하는 HIR 데이터만 예외입니다. (자세히 보기) |
| 토큰 | 파싱의 최소 단위입니다. 토큰은 렉싱 이후에 생성됩니다(자세히 보기). |
| TLS | 스레드 로컬 저장소. 모든 스레드가 변수를 공유하는 대신 스레드마다 고유한 사본을 갖도록 변수를 정의할 수 있습니다. 이는 LLVM과 몇 가지 상호작용이 있습니다. 모든 플랫폼이 TLS를 지원하지는 않습니다. |
| 트레이트 참조(trait reference, trait ref) | 트레이트의 이름과 그에 맞는 제네릭 인자 목록을 함께 이르는 말입니다. (자세히 보기) |
| trans 👎 | translation의 줄임말로, MIR을 LLVM IR로 변환하는 코드입니다. 코드 생성으로 이름이 바뀌었습니다. |
Ty | 타입의 내부 표현입니다. (자세히 보기) |
TyCtxt | 코드에서 흔히 tcx로 불리는 자료 구조로, 세션 데이터와 쿼리 시스템에 접근할 수 있게 해 줍니다. |
| 타입 수준 항(Type-Level Term) | 타입이나 상수 제네릭(Const Generic)처럼 타입 수준에 있는 표현식입니다. |
| UFCS 👎 | 범용 함수 호출 구문(universal function call syntax)의 줄임말로, 메서드를 호출하기 위한 모호하지 않은 구문입니다. 더 이상 사용되지 않는 용어입니다! 완전 한정 경로 / 구문을 사용하십시오. (자세히 보기, 레퍼런스 보기) |
| 거주 불가능 타입(uninhabited type) | 값이 하나도 없는 타입입니다. 이는 정확히 값이 1개인 ZST와는 다릅니다. 거주 불가능 타입의 예로는 enum Foo {}가 있는데, 이 타입은 배리언트가 없으므로 결코 생성될 수 없습니다. 다룰 수 있는 값이 존재하지 않으므로, 컴파일러는 거주 불가능 타입을 다루는 코드를 죽은 코드로 취급할 수 있습니다. !(never 타입)는 거주 불가능 타입입니다. 거주 불가능 타입은 빈 타입(empty types)이라고도 합니다. |
| upvar | 클로저가 클로저 바깥에서 캡처한 변수입니다. |
| 변성(variance) | 제네릭 매개변수의 변화가 서브타이핑에 어떤 영향을 주는지를 결정합니다. 예를 들어 T가 U의 서브타입이면 Vec<T>는 Vec<U>의 서브타입인데, 이는 Vec가 제네릭 매개변수에 대해 공변(covariant)이기 때문입니다. 보다 일반적인 설명은 배경 장을 참고하십시오. 타입 검사가 변성을 어떻게 처리하는지에 대한 설명은 변성 장을 참고하십시오. |
| 배리언트 인덱스(variant index) | 열거형에서 배리언트에 0부터 시작하는 인덱스를 부여하여 각 배리언트를 식별합니다. 이는 순전히 내부적인 것으로, 사용자가 덮어쓸 수 있는 “판별값(discriminant)”(예: enum Bool { True = 42, False = 0 })과 혼동해서는 안 됩니다. |
| 적격성(well-formedness, wfness, wf) | 의미론적으로: 의미 있는 결과로 평가되는 표현식입니다. 타입 시스템에서: 타입 시스템의 규칙을 따르는 타입 관련 구성 요소입니다. |
| 와이드 포인터 | 추가 메타데이터를 가진 포인터입니다. 자세한 내용은 팻 포인터를 참조하십시오. |
| ZST | 크기가 0인 타입(Zero-sized type)입니다. 값의 크기가 0바이트인 타입입니다. 2^0 = 1이므로 이러한 타입은 정확히 하나의 값만 가질 수 있습니다. 예를 들어 ()(유닛)는 ZST입니다. struct Foo;도 ZST입니다. 컴파일러는 ZST와 관련하여 몇 가지 유용한 최적화를 수행할 수 있습니다. |
https://doc.rust-lang.org/reference/glossary.html#glossary도 참조하십시오.
코드 색인
rustc에는 중요한 자료 구조가 많습니다. 이 문서는 컴파일러의 핵심 자료 구조 몇 가지를 더 자세히 알아보려면 어디를 참고하면 되는지 안내하기 위한 것입니다.
| 항목 | 종류 | 간단한 설명 | 장 | 선언 |
|---|---|---|---|---|
BodyId | 구조체 | 네 가지 HIR 노드 식별자 유형 중 하나 | HIR의 식별자 | compiler/rustc_hir/src/hir.rs |
Compiler | 구조체 | 컴파일러 세션을 나타내며 컴파일을 구동하는 데 사용할 수 있습니다. | Rustc 드라이버와 인터페이스 | compiler/rustc_interface/src/interface.rs |
ast::Crate | 구조체 | 파싱된 크레이트의 구문 수준 표현 | 파서 | compiler/rustc_ast/src/ast.rs |
hir::Crate | 구조체 | 크레이트 AST를 더 추상적이고 컴파일러가 다루기 쉬운 형태로 표현한 것 | HIR | compiler/rustc_middle/src/hir/mod.rs |
DefId | 구조체 | 네 가지 HIR 노드 식별자 유형 중 하나 | HIR의 식별자 | compiler/rustc_hir/src/def_id.rs |
Diag | 구조체 | 오류나 린트 같은 컴파일러 진단을 나타내는 구조체입니다 | 진단 내보내기 | compiler/rustc_errors/src/diagnostic.rs |
DocContext | 구조체 | rustdoc이 크레이트를 탐색하며 문서를 수집할 때 사용하는 상태 컨테이너입니다 | Rustdoc | src/librustdoc/core.rs |
HirId | 구조체 | 네 가지 HIR 노드 식별자 타입 중 하나입니다 | HIR의 식별자 | compiler/rustc_hir_id/src/lib.rs |
Lexer | 구조체 | 파싱 중에 사용되는 렉서입니다. 컴파일 중인 원본 소스 코드에서 문자를 읽어 들여, 파서의 나머지 부분에서 사용할 일련의 토큰을 생성합니다 | 파서 | compiler/rustc_parse/src/lexer/mod.rs |
NodeId | 구조체 | 네 가지 HIR 노드 식별자 타입 중 하나입니다. 단계적으로 폐지되고 있습니다 | HIR의 식별자 | compiler/rustc_ast/src/ast.rs |
ParamEnv | 구조체 | 제네릭 매개변수나 Self에 관한 정보로, 연관 항목이나 제네릭 항목을 다룰 때 유용합니다 | 매개변수 환경 | compiler/rustc_middle/src/ty/mod.rs |
ParseSess | 구조체 | 이 구조체는 파싱 세션에 관한 정보를 담고 있습니다 | 파서 | compiler/rustc_session/src/parse/parse.rs |
Rib | 구조체 | 이름의 단일 스코프를 나타냅니다 | 이름 해석 | compiler/rustc_resolve/src/lib.rs |
Session | 구조체 | 컴파일 세션과 연관된 데이터입니다 | 파서, Rustc 드라이버와 인터페이스 | compiler/rustc_session/src/session.rs |
SourceFile | 구조체 | SourceMap의 일부입니다. 단일 소스 파일에 대해 AST 노드를 해당 소스 코드에 매핑합니다. 이전에는 FileMap이라고 불렸습니다 | 파서 | compiler/rustc_span/src/lib.rs |
SourceMap | 구조체 | AST 노드를 해당 소스 코드에 매핑합니다. 여러 SourceFile로 구성됩니다. 이전에는 CodeMap이라고 불렸습니다 | 파서 | compiler/rustc_span/src/source_map.rs |
Span | 구조체 | 사용자 소스 코드 내의 위치로, 주로 오류 보고에 사용됩니다 | 진단 메시지 내보내기 | compiler/rustc_span/src/span_encoding.rs |
rustc_ast::token_stream::TokenStream | 구조체 | TokenTree들로 구성된 추상적인 토큰 시퀀스입니다 | 파서, 매크로 확장 | compiler/rustc_ast/src/tokenstream.rs |
TraitDef | 구조체 | 이 구조체는 트레이트의 정의를 타입 정보와 함께 담고 있습니다 | ty 모듈 | compiler/rustc_middle/src/ty/trait_def.rs |
TraitRef | 구조체 | 트레이트와 그 입력 타입의 조합입니다(예: P0: Trait<P1...Pn>) | 트레이트 해결: 목표와 절 | compiler/rustc_middle/src/ty/sty.rs |
Ty<'tcx> | 구조체 | 타입 검사에 사용되는 타입의 내부 표현입니다 | 타입 검사 | compiler/rustc_middle/src/ty/mod.rs |
TyCtxt<'tcx> | 구조체 | “타이핑 컨텍스트“입니다. 이것은 컴파일러의 중심 자료 구조입니다. 온갖 종류의 쿼리를 수행할 때 사용하는 컨텍스트입니다 | ty 모듈 | compiler/rustc_middle/src/ty/context.rs |
컴파일러 강연 시리즈
다양한 전문가들이 컴파일러의 여러 부분을 설명하는 영상입니다.
컴파일러 아키텍처
- 2022년 9월: 부트스트래핑: 과거와 미래의 컴파일러
- 2024년 11월: Rust 컴파일러 개요
- 2025년 6월: 컴파일러 프런트엔드에 대해 알아야 할 (거의) 모든 것
- 2025년 10월: Stable MIR: Rust 컴파일러 파이프라인을 포크하여 그 너머로
- 2025년 11월: Rust는 어떻게 컴파일되는가
기여와 컴파일러 도구
- 2019년 1월: Tom Tromey가 설명하는 rustc의 디버깅 지원
- 2021년 5월: 컴파일러 내부 데이터 들여다보기(재미와 실익을 위해)
- 2021년 9월: rustc 해킹하기: 컴파일러에 기여하기
- 2022년 10월: Rustc Explore
- 2025년 9월: doctest의 동작 방식
- 2026년 6월: 컴파일러도 결국 프로그램일 뿐입니다: Rust 컴파일러의 버그 수정하기
- 2026년 6월: 트리 외부에서 컴파일러 상태에 접근하기
성능과 컴파일러 테스트
- 2019년 6월: 반응성 있는 컴파일러 - Nicholas Matsakis - PLISS 2019
- 2019년 6월: 내가 배운 것들(TIL) - Nicholas Matsakis - PLISS 2019
- 2024년 11월: Rust 컴파일러 성능의 현재와 미래
- 2025년 3월: Rustlantis: Rust 컴파일러의 무작위 차등 테스트
- 2026년 6월: 정밀하고 일관되며 신뢰할 수 있는 코드 커버리지
- 2026년 6월: 컴파일 유형 사이의 증분 시스템 다시 생각하기
Rust Analyzer
- 2019년 1월: Salsa의 동작 방식
- 2019년 1월: Salsa 더 깊이 알아보기
- 2019년 1월: Rust analyzer 가이드
- 2019년 2월: Rust analyzer 구문 트리
- 2019년 3월: flodiebold의 rust-analyzer 타입 검사기 개요
- 2019년 3월: RLS 2.0, Salsa, 그리고 이름 해석
타입 시스템
- 2015년 7월: Felix Klock - Rust: 원하는 줄 몰랐던 타입 시스템 - Curry On
- 2016년 11월: Felix Klock - Rust의 서브타이핑과 클라크의 제3법칙
- 2019년 2월: 유니버스와 라이프타임
- 2019년 4월: rustc에서 타입을 표현하는 방법
- 2019년 3월: RFC #2229 분리된 필드 캡처 계획
- 2025년 12월:
-Znext-solver: 무엇을, 왜, 언제
클로저
- 2018년 10월: 클로저와 upvar 캡처
- 2018년 10월: blitzerr와 함께하는 클로저 upvar 타입
- 2019년 1월: blitzerr와 함께 클로저 upvar 표현을 튜플로 변환하기
Chalk
- 2018년 7월: Sunjay Varma의 Chalk에서의 일관성(coherence) - Bay Area Rust Meetup
- 2019년 3월: rustc-chalk 통합 개요
- 2019년 4월: chalk-engine 크레이트의 동작 방식
- 2019년 5월: chalk-engine 크레이트의 동작 방식 2
Polonius
Miri
Async
코드 생성
Rust 참고 문헌
이 문서는 Rust와 관련된 자료의 읽기 목록입니다. 여기에는 Rust의 설계에 한때 영향을 준 선행 연구와 Rust에 관한 출판물이 함께 포함되어 있습니다.
타입 시스템
- 별칭 매장(Alias burying) - 저희도 비슷한 것을 시도했다가 포기했습니다.
- External uniqueness is unique enough
- Macros that work together
- Making ad-hoc polymorphism less ad hoc
- Region based memory management in Cyclone
- Region Based Memory Management
- Safe manual memory management in Cyclone
- Skolem Normal Form
- Traits: composable units of behavior
- Uniqueness and Reference Immutability for Safe Parallelism
동시성
- A Java fork/join calamity - Java의 fork/join 라이브러리를 비판하는 글입니다. 특히 비엄격(non-strict) 계산에 작업 훔치기(work stealing)를 적용한 방식을 비판합니다.
- Algorithms for scalable synchronization of shared-memory multiprocessors
- Balanced work stealing for time-sharing multicores
- Contention aware scheduling
- Dynamic circular work stealing deque - Chase/Lev 덱(deque)입니다.
- Epoch-based reclamation.
- Language support for fast and reliable message-based communication in singularity OS
- Non-blocking steal-half work queues
- Reagents: expressing and composing fine-grained concurrency
- Scheduling multithreaded computations by work stealing
- Scheduling techniques for concurrent systems
- Singularity: rethinking the software stack
- The data locality of work stealing
- Thread scheduling for multiprogramming multiprocessors
- Three layer cake for shared-memory programming
- Work-first and help-first scheduling policies for async-finish task parallelism - 완전 엄격(fully-strict) 작업 훔치기보다 더 일반적인 방식입니다.
기타
Rust에 관한 논문
- Rust로 하는 GPU 프로그래밍: 시스템 수준 언어에서 고수준 추상화 구현하기. Eric Holk의 초기 GPU 연구입니다.
- 병렬 클로저: 오래된 아이디어의 새로운 변주
- 엄밀히 말해 Rust에 관한 것은 아니지만 nmatsakis가 작성했습니다.
- Patina: Rust 프로그래밍 언어의 형식화. Eric Reed가 작성한, 타입 시스템 일부에 대한 초기 형식화입니다.
- 경험 보고서: Rust를 사용한 Servo 웹 브라우저 엔진 개발. Lars Bergstrom이 작성했습니다.
- Rust로 제네릭 기수 트라이 구현하기. Michael Sproul의 학부 논문입니다.
- Reenix: Rust로 유닉스 계열 운영 체제 구현하기. Alex Light의 학부 논문입니다.
- HPC 환경에서 후보 프로그래밍 언어들의 성능 및 생산성 지표 평가. Florian Wilkens의 학사 학위 논문입니다. C, Go, Rust를 비교합니다.
- Nom: Rust로 작성된 바이트 지향, 스트리밍, 제로 카피 파서 컴비네이터 라이브러리. Geoffroy Couprie가 작성했으며, VLC를 위한 연구입니다.
- 그래프 기반 고차 중간 표현. Rust와 유사한 언어인 Impala로 구현된 실험적인 중간 표현(IR)입니다.
- 스텐실 코드의 코드 정제. Impala를 사용한 또 다른 논문입니다.
- fork-join 등을 활용한 Rust에서의 병렬화. Linus Farnstrand의 석사 학위 논문입니다.
- Rust를 위한 세션 타입. Philip Munksgaard의 석사 학위 논문입니다. Servo를 위한 연구입니다.
- 소유권은 절도다: Rust로 임베디드 OS를 구축한 경험 - Amit Levy 외
- You can’t spell trust without Rust. Aria Beingessner의 석사 학위 논문입니다.
- Rust-Bio: 빠르고 안전한 생물정보학 라이브러리. Johannes Köster
- Safe, Correct, and Fast Low-Level Networking. Robert Clipsham의 석사 학위 논문입니다.
- Formalizing Rust traits. Jonatan Milewski의 석사 학위 논문입니다.
- Rust as a Language for High Performance GC Implementation
- Simple Verification of Rust Programs via Functional Purification. Sebastian Ullrich의 석사 학위 논문입니다.
- 2017년답게 파서 작성하기 Pierre Chifflier, Geoffroy Couprie, Langsec Workshop 발표
- The Case for Writing a Kernel in Rust
- RustBelt: Securing the Foundations of the Rust Programming Language
- Oxide: The Essence of Rust. Aaron Weiss, Olek Gierczak, Daniel Patterson, Nicholas D. Matsakis, Amal Ahmed 공저입니다.
독서 모임 세션
개발 가이드는 방대하고 내용이 촘촘하며, 해당 분야 전문가가 작성한 경우가 많습니다. 때때로 메인테이너를 비롯해 관심 있는 사람이라면 누구나 기존 장을 함께 읽으며 내용을 이해하려고 노력합니다. 이 부록은 그러한 Zulip 토론을 정리한 색인입니다.
- 2024년 2월 26일: 컴파일러 아키텍처, 쿼리, 부트스트랩, 직렬화, rustdoc
- 2024년 3월 11일: 부트스트랩 심층 탐구
- 2024년 4월 8일: 구문과 AST, 첫 논의
- 2024년 4월 17일: 구문, AST, 파싱, 확장, 이름 해석
- 2024년 5월 2일: HIR과 THIR
- 2024년 5월 29일: MIR
- 2024년 11월 20일: 컴파일러 식별자, 클로저 캡처, 인라인 어셈블리, 가이드 구성
- 2024년 12월 4일: 제네릭 매개변수와 조기 바인딩 및 후기 바인딩 매개변수
- 2025년 3월 19일: 진단과 린트
계획과 일반적인 논의는 독서 모임 조율 토픽을 참고하십시오.
Rust의 유머
유머 감각이 없는 프로젝트가 무슨 의미가 있겠습니까? 그리고 솔직히 이 중 몇몇은 깨달음을 주기도 하지 않습니까?