`const fn` 기반(컴파일 타임에서만 호출 가능) 리플렉션 스킴을 설계·구현하고, 실험적으로 도입하기 위한 제안.
| 메타데이터 | |
|---|---|
| 담당자(Point of contact) | Oliver Scherer |
| 상태(Status) | 제안됨(Proposed) |
| 기타 추적 이슈(Other Tracking issue) | rust-lang/rust#142577 |
| Zulip 채널 | 해당 없음(N/A) (기존 스트림을 재사용하거나 요청 시 새 스트림 생성 가능) |
| compiler 챔피언 | Oliver Scherer |
| lang 챔피언 | Scott McMurray |
| libs 챔피언 | Josh Triplett |
| 추적 이슈(Tracking issue) | rust-lang/rust-project-goals#406 |
| 팀(Teams) | compiler, lang, libs |
| 작업 소유자(Task owners) | oli-obk |
오직 컴파일 타임에서만 호출될 수 있는 const fn에 기반한 리플렉션(reflection) 스킴을 설계하고 구현하여, 실험적으로 도입한다. 이 제안은 const eval 값을 생성하기 위한 것일 뿐이며, 타입을 다시 타입 시스템으로 되돌려 넣는 것을 목표로 하지는 않는다. 그 부분은 이 제안의 MVP가 병합된 이후 후속으로 진행될 것이다.
오늘날 거의 모든 다른 데이터 구조와 함께 동작해야 하는 새로운 범용 크레이트(예: 직렬화 크레이트, 로그/트레이싱 크레이트, 게임 엔진 상태 검사 크레이트)를 만드는 일은 쉽지 않다. 보통은 다음 중 하나가 필요하다.
이는 종종 도입(rollout)을 방해하며, 모든 크레이트에까지 확산되기는 불가능하다. 대부분의 크레이트 메인테이너는 직렬화 크레이트 2개 이상, 로깅 크레이트 3개 이상에 의존하고 싶어 하지 않으므로, 결국 하나를 선택하게 된다. 그 결과 모두가 크고 인기 있는 크레이트를 선택하거나, 직렬화/로깅할 수 있는 대상이 제한되는 상황에 처한다. 이는 혁신을 저해하며, 장기적으로(개인 의견으로) 어떤 문제에 대해 객관적으로 더 나은 해결책이 등장하더라도 생태계가 진화하지 못하게 만들 수 있다.
리플렉션은 이 딜레마를 해결할 길을 제공한다. 즉, 모든 타입에 대해 동작하는 로직을 작성할 수 있다. 리플렉션 코드를 실행하는 동안 값의 타입을 조사하여, 런타임에 타입 정보를 처리할 수도 있고(또는 const 블록 등으로 컴파일 타임에 전처리할 수도 있으며), 함수에 트레이트 바운드를 걸거나 어디엔가 트레이트 impl을 둘 필요도 없다. 이는 직렬화/로깅/게임 엔진을 소비하는 쪽을 제외하면 아무도 당신의 크레이트를 알 필요가 없고, crates.io 생태계 전체가 당신의 트레이트에 대한 derive를 추가할 필요도 없다는 뜻이다. 소비자는 즉시, 크기에 제한 없는 튜플, 임의의 구조체와 열거형(당신의 크레이트에 의존하지도 않고 당신이 그들에 의존하지도 않는 임의의 크레이트에서 온 것들)과 상호운용할 수 있게 된다.
이 실험이 성공한다면, bevy 같은 크레이트는 bevy_reflect 정보를 컴파일 타임에 만들기 위해 타입에 #[derive(Component)], #[derive(Bundle)], #[derive(Resource)]를 붙이도록 작성자에게 요구하는 대신, 임의의 타입과도 “그냥 동작”할 수 있게 된다. bevy_reflect나 facet 같은 크레이트는 계속 존재하겠지만, 리플렉션 정보를 노출하는 서로 다른 목표와 방법을 가진 서로 다른 라이브러리로서 존재하게 될 것이다.
또한 이는 다음과 같은 새로운 “리플렉션 유사” 동작의 가능성을 연다.
나는 리플렉션이 derive와 직교(orthogonal) 관계라고 본다. 둘은 서로 다른 방향에서 비슷한 문제를 푼다. 리플렉션은 동적 언어와 매우 비슷한 방식으로, 리플렉션 코드 실행 중 값의 타입을 검사해 타입을 처리하는 로직을 작성하게 해준다. 반면 derive는 타입을 처리하는 코드를 사전에 생성한다. 프로시저 매크로 derive는 역사적으로 디버깅이 꽤 어렵고, 처음부터 부트스트랩하기도 어렵다는 점이 드러났다(프로시저 매크로 워크플로도 분명 개선해야 한다). 리플렉션 역시 금세 복잡해질 수 있지만, derive 로직을 소비자 로직(예: serializer)과 짝지어야 하는 대신 소비자 로직만 직접 작성하는 형태이므로, 더 동적인 접근이 가능하고 현재 상태를 더 쉽게 디버깅할 수 있다.
리플렉션은 종종 derive만큼 효율적이지 않은데, derive는 사전에 이상적인 코드를 생성할 수 있기 때문이다. 하지만 어떤 유스케이스에 대해 완전히 동작하는 리플렉션 시스템을 만든 뒤 성능이 문제가 된다면, 처음부터 derive로 시작하는 것보다, 성능이 중요한 케이스에 대해 derive를 작성하는 편이 훨씬 쉬워야 한다.
const fn에 대해, 런타임 코드에서(또는 그 속성이 없는 const fn에서) 호출할 수 없도록 막는 속성을 추가한다.
#[rustc_comptime] const fn() {} 선언이 필요한지는 FAQ를 참고.타입에 대한 공통 정보와 그 정보를 얻기 위한 API를 표현하는 기본 데이터 구조를 libcore에 추가한다.
facet, bevy-reflect, reflect가 derive나 트레이트 바운드 없이도 타입을 처리할 수 있게 해주는 기본 빌딩 블록을 만든다.
연관 상수(associated const) 기반 설계보다 절차적(const-eval) 코드를 선호한다(FAQ의 “왜 uwuflection은 아닌가”도 참고).
const fn을 통한 일반적인 평가(evaluation)를, 표현력은 같지만 사실상 DSL인 연관 상수 기반 설계보다 선택했다.size_of가 새로운 비공개 필드 추가 여부를 노출하는 것 같은 예외를 제외하고, 프라이버시가 유지되도록 한다.
새로운 semver 위험 요소를 피하고, 불가피하다면 문서화한다.
이 섹션은 선택 사항이지만, 설계 공리를 포함하면 제약과 트레이드오프를 어떻게 균형 잡을지(예: “성능보다 사용 편의성 우선” 또는 그 반대)를 알리는 데 도움이 된다. 팀들은 공리를 검토하고 동의하는지 확인해야 한다. 설계 공리에 대해 더 읽기.
이 섹션은 수행할 작업과 Rust 팀에 요청할 사항을 나열한다. 표의 각 행은 기여자가 수행하는 항목이거나 팀에 요청하는 항목이어야 한다.
대부분의 목표에서는 표 하나로 충분하지만,
###로 하위 섹션을 추가할 수도 있다. 아래에는 가장 일반적인 목표 유형을 보여주는 예시 하위 섹션들이 있다. 표의 항목은 향후 6개월 동안 계획한 것에만 해당해야 한다.기여자가 수행하는 항목의 경우 기여자를 적거나, 아직 누가 할지 모르면 ![Heap wanted][]를 적는다. 오너는 이상적으로
@ghost처럼 GitHub 사용자명으로 식별한다.팀에 요청하는 항목의 경우 ![Team][] 과 팀 이름(예:
![Team][] [compiler]또는![Team][] [compiler], [lang])을 적는다(![Team][]의 뒤[]는 마크다운 파싱을 위해 필요). 팀 요청의 경우 “task”는 rust-project-goals.toml에 정의된 작업 중 하나여야 하며, 그렇지 않으면cargo rpg check가 오류를 낸다.
| 작업(Task) | 오너(Owner) 또는 팀(Team) | 비고(Notes) |
|---|---|---|
| 논의 및 정신적 지원 | ||
| 작업 수행 | oli-obk |
일부 목표는 문제를 해결하기 위한 기능 설계를 제안한다. 보통 이 목표의 결과물은 RFC 초안 또는 채택된 RFC다. RFC가 채택되기 전에 트리 내부(in-tree)에서 실험적 구현을 하고 싶다면 lang 팀 실험을 만들 수 있지만, 신뢰할 수 있는 기여자가 필요하다.
| 작업(Task) | 오너(Owner) 또는 팀(Team) | 비고(Notes) |
|---|---|---|
| lang-team 실험 | specialization 데이터를 사용할 수 있게 하려면 libstd 데이터 구조(lang item)가 필요 |
수락된 RFC가 있거나 lang-team 실험을 진행 중이라면, 보통 누군가가 코드를 작성해야 하고, 컴파일러 팀의 PR 리뷰 지원이 필요하며, 흥미로운 설계 질문을 검토하기 위한 lang 팀 설계 미팅이 필요할 수도 있다. 구현이 완료되면 테스트 요청(call for testing) 블로그 포스트를 권장한다.
| 작업(Task) | 오너(Owner) 또는 팀(Team) | 비고(Notes) |
|---|---|---|
| 구현(Implementation) | oli-obk | |
| 표준 리뷰(Standard reviews) | ||
| 설계 미팅(Design meeting) | ||
| 테스트 요청 블로그 포스트 작성 | 일반적인 테스트 요청은 하지 않고, bevy 또는 facet으로 실험만 할 가능성이 큼 |
만약 libcore에 bevy_reflect::Type 같은 타입이 있고,
rustconst fn type_of(id: TypeId) -> &'static Type;
같은 함수가 있다면, 그리고 이 함수가(다른 모든 const fn과 달리) 런타임에서 호출될 수 없다는 특별한 요구 조건을 가진다면, 일반적인 절차적 Rust 코드에서 타입 설명을 다룰 수 있게 된다.
따라서 이 실험적 구현에서는 다음과 같이 할 것이다.
rust#[compile_time_only] const fn type_of(id: TypeId) -> &'static Type;
이 함수들은 런타임에서 실행될 수 없다. 런타임에서 실행하려면 모든 TypeId를 그 표현(repr)에 매핑하는 어떤 전역 테이블이 어딘가에 존재해야 하기 때문이다. 개인적으로 이는 명백히 받아들일 수 없다.
데모 구현(“병합 가능한 형태로 개선”하는 것은 절대 불가능한 수준이며, 실제로 랜딩할 수 있는 것에 재활용할 수 없음!)은 여기에서 볼 수 있다: https://github.com/rust-lang/rust/compare/master...oli-obk:rust:compile-time-reflection
uwuflection이 무엇인지에 대한 자세한 내용은 다음을 참고: https://soasis.org/posts/a-mirror-for-rust-a-plan-for-generic-compile-time-introspection-in-rust/
이 방식은 타입 정보의 절차적 처리를 매우 어렵게 만든다. 예를 들어 튜플의 3번째 원소를 얻으려면 다음이 필요하다.
rust<introwospect_type::<YourType> as FieldDescriptor<3>>
즉, 그 인덱스를 계산하려면 상수가 필요하다. 인덱스를 순회하기 위해 for 루프를 그냥 사용할 수 없다.
우리는 const fn을 선택했는데, 연관 상수(associated const)와 제네릭으로도 const fn과 동일한 것을 계산할 수는 있지만, 더 비싸고 대부분 순수 함수형(purely functional)으로 돌아가기 때문이다. 따라서 다시 연관 상수로 돌아가는 것은 그 선택에 반하는 것으로 보인다.
이는 언어에 컴파일 타임 for 루프 기능을 추가하면 어느 정도 해결될 수 있는데, 그 기능은 매크로나 루프 언롤링(loop-unrolling)처럼 바디를 N번 확장하는 방식이 될 것이다.
제네릭 코드에서 타입 안에 uwuflection을 사용하려면, min const generics로 실패하지 않는(infallible) 코드를 작성하거나, 아니면 uwuflection 바운드(typenum을 떠올리면 될 정도로 많이)를 추가해야 한다. 이는 목적에 반한다.
매우 높은 수준에서 본 zig의 comptime 접근은 사실상 다음과 같다.
main 함수를 선택하고, 그것을 컴파일하기 시작하면서 컴파일에 필요한 것들을 찾아간다.현재는 이 접근을 실험하지 않는다. 컴파일러가 현재 크레이트의 타입 정보에 프로시저 매크로가 접근하는 것을 허용하도록 구성되어 있지 않기 때문이다. 그 방향으로의 리팩터링이 진행 중이긴 하지만, 최선의 추정으로도 그 미래는 5년 이상은 남아 보인다.