러스토노미콘
경고: 이 책은 미완성입니다. 모든 것을 문서화하고, 예전에는 맞았지만 지금은 아닌 내용들을 고치는 데는 시간이 걸립니다. 이슈 트래커에 없는 기능이나, 예전에는 맞았지만 지금은 아닌 내용들을 보실 수 있고, 아직 보고되지 않은 실수나 생각들이 있다면 거기에 얼마든지 새로운 이슈를 열어 주세요.
불안전 러스트의 흑마법들
이 지식은 "있는 그대로" 제공되며, 표현할 수 없는 공포스러운 것들을 해방시켜 당신의 정신을 산산조각내고, 당신의 마음을 알 수 없는 무한한 우주에 떠다니게 하는 것, 혹은 그 이상의 범위를 포함한 사항에 있어서, 명시적 혹은 묵시적인 어떠한 보증도 하지 않는다.
러스토노미콘은 불안전한 러스트 프로그램을 작성할 때 알아야 하는 모든 무시무시한 하나하나를 다 파헤칩니다.
만일 당신이 러스트 프로그램을 작성하는 데 있어서 길고 행복한 나날을 바란다면, 당장 뒤돌아서서 이 책을 봤다는 것도 잊어버리세요. 이것이 필수적인 것은 아닙니다. 그러나 당신이 불안전한 코드를 쓰려고 하거나 - 아니면 그냥 언어의 속을 파 보고 싶다면 - 이 책은 유용한 정보를 많이 담고 있습니다.
러스트 프로그래밍 언어 와 다르게, 상당한 양의 사전 지식을 갖추고 있다고 가정하겠습니다. 특별히, 당신은 기본적인 시스템 프로그래밍과 러스트에 익숙해야 합니다. 이 주제들이 익숙하지 않다면, 기본 책 을 먼저 읽으셔야 할 겁니다. 그렇긴 하지만, 그 책을 읽었다고 가정하진 않을 것이고, 필요하다고 여기는 부분에서는 때때로 기본을 다시 다질 것입니다. 원하시면 바로 이 책으로 건너뛰어도 됩니다 - 모든 것을 처음부터 설명하지는 않을 거라는 것만 알아 두세요.
이 책은 주로 높은 수준에서 러스트 언어 참조서(영문) 와 함께 가는 용도로 존재합니다. 참조서가 언어의 모든 부분의 문법과 의미를 자세하게 알기 위해 존재한다면, 러스토노미콘은 이런 부분들을 어떻게 짜맞추어 쓰느냐, 그리고 그러는 동안 부딪힐 난관들을 조명하기 위해 존재합니다.
참조서는 레퍼런스, 소멸자, 그리고 되감기에 대한 문법과 의미를 말해 주겠지만, 그들을 결합하는 것이 어떻게 예외 내구성 관련 문제를 가져다 줄 수 있는지, 혹은 그 문제들을 어떻게 해결해야 하는지를 말해 주지는 않을 겁니다.
알아두실 필요가 있는 것은 러스토노미콘과 참조서를 잘 동기화하진 않아서, 중복된 내용을 발견하실 수도 있다는 점입니다. 보통 두 문서가 내용이 일치하지 않는다면, 참조서가 맞다고 보는 것이 좋습니다 (참조서가 표준은 아닙니다. 그냥 더 잘 관리될 뿐입니다).
이 책의 범위 안에 있는 주제들은 다음과 같습니다:
- (불)안전의 의미
- 언어와 표준 라이브러리에서 재공되는 불안전한 기본 연산들
- 그 불안전한 기본 연산들로 안전한 추상화를 만드는 기법들
- 부분타입과 변성
- 예외 안전성 (
panic!
/되감기 안전성) - 초기화되지 않은 메모리를 가지고 작업하기
- 타입 가지고 놀기
- 병렬성
- 다른 언어와 상호작용하기 (FFI)
- 최적화 기법
- 구조들이 어떻게 컴파일러/OS/하드웨어 기본 연산들로 변환되는지
- 메모리 모델을 화나지 않게 하는 법
- 메모리 모델을 화나게 하는 법
- 기타 등등
러스토노미콘은 표준 라이브러리의 모든 API 하나하나의 의미와 보장되는 사항을 일일이 설명하는 곳이 아니고, 러스트의 모든 기능을 하나하나 설명하는 곳도 아닙니다.
다른 말이 없으면, 이 책의 러스트 코드는 러스트 2021 에디션을 사용합니다.
안전함과 불안전함을 마주하라
낮은 레벨의 구현 세부사항에 대해 걱정하지 않아도 되면 참 좋을 것입니다. 빈 튜플이 얼마만큼의 공간을 차지하는지 대체 누가 신경쓸까요? 슬프게도 이런 것들은 어떤 때에는 중요하고, 우리는 이런 것들을 걱정해야 합니다. 개발자들이 구현 세부사항에 대해서 걱정하기 시작하는 가장 흔한 이유는 성능이지만 그보다 더 중요한 것은, 하드웨어, 운영체제, 혹은 다른 언어들과 직접적으로 상호작용할 때 이런 세부적인 것들이 올바른 코드를 작성하는 것에 대한 문제가 될 수 있습니다.
안전한 프로그래밍 언어에서 구현 세부사항이 중요해지기 시작할 때, 프로그래머들은 보통 3가지 선택지가 있습니다:
- 컴파일러나 런타임이 최적화를 수행하도록 코드에 꼼수 쓰기
- 원하는 구현을 얻기 위해 훨씬 부자연스럽거나 거추장스러운 디자인을 채용하기
- 구현을 그런 세부사항을 다룰 수 있는 언어로 다시 작성하기
마지막 선택지에서, 프로그래머들이 주로 쓰는 언어는 C 입니다. 이 언어는 C 인터페이스만 정의하는 시스템들과 상호작용하기 위해 보통 필요합니다.
불행하게도 C는 (때때로 좋은 이유로) 사용하기에 엄청나게 불안전하고, 다른 언어들과 협업하려고 할 때 이 불안전함은 증폭됩니다. C와 다른 언어가 어떤 일이 일어날지 서로 동의하고, 서로의 발을 밟지 않게 하기 위해 주의가 필요합니다.
그래서 이것이 러스트와 무슨 상관일까요?
그게, C와 다르게, 러스트는 안전한 프로그래밍 언어입니다.
하지만, C와 마찬가지로, 러스트는 불안전한 언어입니다.
더 정확하게 말하자면, 러스트는 안전한 언어와 불안전한 언어 두 가지를 모두 포함하고 있습니다.
러스트는 안전한 러스트 와 불안전한 러스트 라는 두 개의 프로그래밍 언어의 조합이라고 볼 수 있습니다. 편리하게도, 이 이름들은 말 그대로입니다: 안전한 러스트는 안전하고, 불안전한 러스트는, 음, 그렇지 않죠. 사실 불안전한 러스트는 매우 불안전한 것들을 할 수 있게 해 줍니다. 러스트 개발자들이 하지 말라고 간곡히 부탁하는 것들이지만, 우리는 그냥 무시하고 할 겁니다.
안전한 러스트는 진정한 러스트 프로그래밍 언어입니다. 만약 안전한 러스트만 작성한다면, 타입 안정성이나 메모리 안정성은 절대 걱정할 필요가 없을 겁니다. 달랑거리는 포인터나, 해제 후 사용, 혹은 다른 종류의 미정의 동작은 겪지 않을 테니까요.
표준 라이브러리도 또한 당신에게 바로 쓸 수 있는 도구들을 풍부하게 주어서 당신이 순수하고 자연스러운, 안전한 러스트로 고성능의 응용 프로그램을 만들 수 있게 해 줍니다.
하지만 혹시 당신이 다른 언어에게 말을 걸고 싶을 수도 있습니다. 표준 라이브러리가 노출하지 않은 저수준의 추상화를 작성하고 있을 수도 있죠. 당신이 (순수하게 러스트로 만들어진) 표준 라이브러리를 작성하고 있을 수도 있습니다. 타입 시스템이 이해하지 못하는 것을 해야 할 수도 있고 그냥 좀 비트들에 장난질을 하고 싶을 수도 있습니다. 당신은 불안전한 러스트가 필요할지도 모릅니다.
불안전한 러스트는 안전한 러스트와 아주 흡사합니다. 안전한 러스트의 모든 규칙들과 의미들을 담고 있죠. 다만 추가적으로 절대로 안전하지 않은 것들을 할 수 있게 해 줍니다 (이에 대해서는 다음 섹션에서 다루겠습니다).
이런 구분을 통해 우리는 C와 같은 불안전한 언어의 이점 - 구현 세부사항에 대한 저수준 제어 - 을 얻으면서 완전히 다른 안전한 언어에 통합하려고 할 때 생기는 문제들 대부분을 피할 수 있게 됩니다.
몇몇 문제들이 아직 있긴 합니다 - 대표적으로 타입 시스템이 가정하는 속성들에 대해서 인식하고, 불안전한 러스트와 상호작용하는 어떤 코드에도 그 속성들을 검증해야 합니다. 그게 바로 이 책의 목적입니다: 이런 가정들에 대해서, 또 이것들을 어떻게 관리해야 하는지 가르쳐주는 것이죠.
안전함과 불안전함은 어떻게 상호작용하는가
안전한 러스트와 불안전한 러스트는 어떤 관계일까요? 둘은 어떻게 상호작용할까요?
안전한 러스트와 불안전한 러스트 간의 구분은 unsafe
라는 키워드로 제어되는데, 이것은 서로에게 인터페이스 역할을 합니다.
이것이 바로 안전한 러스트는 안전한 언어라고 할 수 있는 이유입니다: 모든 불안전한 부분은 unsafe
라는 경계 뒤로 밀리거든요.
원한다면 #![forbid(unsafe_code)]
를 코드베이스에 집어넣음으로써 오직 안전한 러스트만 쓴다는 것을 컴파일할 때 보장할 수도 있죠.
unsafe
키워드는 두 가지 용도가 있습니다: 컴파일러가 확인할 수 없는 계약의 존재를 정의할 때 사용하고, 또한
이 계약들이 성립한다는 것을 프로그래머가 확인했다고 선언할 때 사용합니다.
함수들 과 트레잇 정의들 에서 확인되지 않은 계약들의 존재를 알리기 위해 unsafe
키워드를 쓸 수 있습니다.
함수에서 unsafe
는 함수의 사용자들이 함수의 문서를 확인해서, 함수가 요구하는 계약을 지키는 방식으로 사용해야
한다는 것을 의미합니다. 트레잇 정의에서 unsafe
는 트레잇의 구현자들이 트레잇 문서를 확인해서 그들의 구현이
트레잇이 요구하는 계약을 지키는 것을 확실히 해야 한다는 것을 뜻합니다.
코드 블럭에도 unsafe
를 사용해서 그 안에서 이루어진 모든 불안전한 작업들이 그 작업들의 계약들을 지켰다는
것을 확인했다고 선언할 수 있습니다. 예를 들어, slice::get_unchecked
에 넘겨진 인덱스는
범위 안에 있어야 합니다.
트레잇 구현에 unsafe
를 사용해서 그 구현이 트레잇의 계약을 지킨다고 선언할 수 있습니다. 예를 들어, Send
를
구현하는 타입은 정말로 다른 스레드로 안전하게 이동할 수 있어야 합니다.
표준 라이브러리는 다음을 포함한 다수의 불안전한 함수들을 가지고 있습니다:
slice::get_unchecked
는 범위를 확인하지 않고 인덱싱을 하기 때문에 메모리 안정성이 자유롭게 침해되도록 허용합니다.mem::transmute
는 어떤 값을 주어진 타입으로 재해석하여 임의의 방식으로 타입 안정성을 건너뜁니다 (자세한 사항은 변환 을 참고하세요).- 사이즈가 정해진 타입의 모든 생(raw)포인터는
offset
메서드가 있는데, 이 메서드는 전달된 편차(offset)가 "범위 안에 있지" 않을 경우 미정의 동작을 일으킵니다. - 모든 외부 함수 인터페이스 (FFI) 함수들은 호출하기에
불안전
합니다. 이는 다른 언어들이 러스트 컴파일러가 확인할 수 없는 임의의 연산들을 할 수 있기 때문입니다.
러스트 1.48.0 버전에서 표준 라이브러리는 다음의 불안전한 트레잇들을 정의하고 있습니다 (다른 것들도 있지만 아직 안정화되지 않았고, 어떤 것들은 나중에도 안정화되지 않을 것입니다):
Send
는 이를 구현하는 타입들이 다른 스레드로 이동해도 안전함을 약속하는 표시 트레잇(API가 없는 트레잇)입니다.Sync
는 또다른 표시 트레잇으로, 이를 구현하는 타입들을 불변 레퍼런스를 이용해 스레드들이 서로 공유할 수 있음을 약속합니다.GlobalAlloc
은 프로그램 전체의 메모리 할당자를 커스터마이징할 수 있게 해 줍니다.SliceIndex
는 슬라이스 타입들의 인덱싱을 위한 동작을 정의합니다. 여기에는 경계를 확인하지 않고 인덱싱하는 작업이 포함됩니다.
러스트 표준 라이브러리도 내부적으로 불안전한 러스트를 꽤 많이 씁니다. 이 구현사항들은 수동으로 엄격하게 확인되어서, 이 위에 안전한 러스트로 지은 인터페이스들은 안전하다고 생각해도 됩니다.
이런 구분의 필요성은 건전성 이라고 불리는, 안전한 러스트의 근본적인 특성으로 귀결됩니다:
무슨 일을 하던, 안전한 러스트는 미정의 동작을 유발할 수 없습니다.
안전/불안전으로 구분하는 디자인은 안전한 러스트와 불안전한 러스트 사이에 비대칭적 신뢰 관계가 있다는 것을 의미합니다. 안전한 러스트는 본질적으로 모든 불안전한 러스트 코드가 올바르게 작성되었다고 믿어야 합니다. 반면 불안전한 러스트는 부주의하게 작성한 안전한 러스트 코드를 믿을 수 없습니다.
예를 들어, 러스트는 "그냥" 비교할 수 있는 타입과 "완전한" 순서를 가지고 있는 (즉 비교가 합리적으로 이루어지는)
타입을 구분하기 위해 PartialOrd
와 Ord
트레잇을 가지고 있습니다.
BTreeMap
은 불완전한 순서를 가지는 타입들에 쓰는 것은 말이 안 되기 때문에 키 역할을 하는 타입이 Ord
를 구현하도록 요구합니다.
하지만 BTreeMap
은 구현 내부에 불안전한 러스트 코드가 있습니다. 안전한 러스트 코드이긴 하겠지만, 부주의한 Ord
구현이 미정의 동작을 일으키는 것은 받아들일 수 없기 때문에,
BTreeMap
에 있는 불안전한 코드는 완전하게 순서를 이루고 있지 않은 Ord
구현을 견딜 수 있도록 작성되어야 합니다 - 비록 그렇기 때문에 Ord
를 요구한다고 해도요.
불안전한 러스트 코드는 안전한 러스트 코드가 잘 작성되었을 것이라고 마냥 믿을 수가 없습니다. 그래서 말하자면, BTreeMap
은 당신이 완전한 순서를 이루지 않는 값들을 집어넣으면 완전히 예측 불가능하게 행동할 겁니다. 다만 미정의 동작은 절대로 일으키지 않을 겁니다.
이렇게 질문할 수도 있습니다, 만약 BTreeMap
이 Ord
가 안전해서 믿을 수 없다면, 다른 안전한 코드는 어떻게 믿죠? 예를 들어 BTreeMap
은 정수들과 슬라이스 타입들이 올바르게 구현되었을 거라고 가정합니다. 그것들도 안전하잖아요, 그죠?
그 차이는 범위의 차이입니다. BTreeMap
이 정수들과 슬라이스들에 의존할 때, 그건 매우 특정한 구현에 의존하는 것입니다. 이것은 이득을 생각할 때 넘겨 버릴 수 있는, 일정한 부담입니다. 이 경우에서는 비용이 없다고 할 수 있습니다; 만약 정수들과 슬라이스들이 오류가 있다면, 모두가 오류가 있는 거니까요. 게다가 그것들은 BTreeMap
을 관리하는 사람들의 손에 관리되기 때문에, 그 구현들을 지켜보기 쉽죠.
반면에 BTreeMap
의 키 타입은 제네릭입니다. 그 Ord
구현을 믿는 것은 과거, 현재, 미래의 모든 Ord
구현을 믿는 것과 같습니다.
여기서의 비용은 큽니다: 어디서 누군가는 실수를 해서 본인의 Ord
구현을 망치거나, 심지어는 "그냥 되는 것처럼 보여서" 완전한 순서를 가지는 것처럼 거짓말을 할 수도 있습니다.
그런 일이 벌어질 때 BTreeMap
은 대비해야 합니다.
당신에게 전달된 클로저가 올바르게 작동할 거라고 믿는 것에도 동일한 논리가 적용됩니다.
광범위한 제네릭을 신뢰하는 이런 문제는 unsafe
트레잇을 이용하여 해결할 수 있습니다. 이론적으로 BTreeMap
는 키 타입이 Ord
가 아니라 UnsafeOrd
라고 불리는 새로운 트레잇을 구현하도록 요구할 수도 있습니다.
이 트레잇은 이렇게 생겼습니다:
#![allow(unused)] fn main() { use std::cmp::Ordering; unsafe trait UnsafeOrd { fn cmp(&self, other: &Self) -> Ordering; } }
그러면 타입이 UnsafeOrd
를 구현할 때 unsafe
키워드를 쓰겠지요. 그 말은 그들의 트레잇이 기대하는 계약을 지켰다는 것을 그들이 확인했다는 뜻일 겁니다.
이런 상황에서 BTreeMap
내부의 불안전 러스트는 키 타입의 UnsafeOrd
구현이 맞다고 신뢰하는 것이 정당화됩니다. 만약 그 구현이 틀렸다면 그것은 불안전한 트레잇 구현의 문제이고, 이것은 러스트의 안전성 보장에 부합합니다.
트레잇을 unsafe
로 표시할지는 API 디자인 선택입니다. 안전한 트레잇이 구현하기에는 더 쉽지만, 그 코드에 의존하는 모든 불안전한 코드는 잘못된 동작을 방어해야 합니다.
트레잇을 unsafe
로 표시하면 그 책임이 구현하는 사람에게로 넘어갑니다. 러스트는 전통적으로 트레잇들을 unsafe
로 표시하는 것을 피해 왔는데, 만약 그러면 불안전한 러스트를 널리 퍼지게 하고, 그것은 별로 바람직하지 않기 때문입니다.
Send
와 Sync
는 불안전으로 표시되어 있는데, 그것은 스레드 안정성은 불안전한 코드가 방어할 수 없는 근본적인 특성이라 버그가 있는 Ord
구현을 방어할 때처럼 막을 수 없기 때문입니다.
마찬가지로, GlobalAllocator
는 프로그램의 모든 메모리를 관리하고, Box
나 Vec
같은 다른 것들이 그 위에 지어져 있습니다. 만약 이게 이상한 짓을 한다면 (이미 사용중인 메모리를 할당할 때 반환한다던가), 그것을 인지하거나 대비할 수 있는 가능성은 없습니다.
당신이 만든 트레잇들을 unsafe
로 표시할지 여부는 같은 종류의 고민을 해 봐야 합니다. 만약 unsafe
코드가 이 트레잇의 잘못된 구현을 방어할 수 없다고 합리적으로 생각될 때, 이 트레잇을 unsafe
로 표시하는 것은 합리적인 선택입니다.
한편 Send
와 Sync
가 unsafe
트레잇이지만, 동시에 어떤 타입들에게는 자동으로 구현되는데, 이런 구현이 아마도 안전하다고 여겨지는 타입들입니다. Send
는 Send
를 구현하는 타입들로만 이루어진 타입에 자동으로 구현됩니다.
Sync
는 Sync
를 구현하는 타입들로만 이루어진 타입에 자동으로 구현됩니다. 이런 자동 구현은 이 두 트레잇들을 unsafe
로 만들어서 불안전성이 널리 퍼지는 것을 최소화합니다.
그리고 메모리 할당자를 직접 만드는 사람은 많지 않을 겁니다 (그것을 직접적으로 쓰는 사람도요).
이것이 바로 안전한 러스트와 불안전한 러스트 사이의 균형입니다. 이런 구분은 최대한 자연스럽게 안전한 러스트를 쓸 수 있도록 하되, 불안전한 러스트를 쓸 때는 추가적인 노력과 주의를 요하게 설계되었습니다. 이 책의 나머지 부분은 주로 어떤 종류의 주의가 필요한지와 불안전한 러스트가 지켜야 할 계약들이 어떤 것들인지를 논합니다.
불안전한 러스트는 무엇을 할 수 있는가
불안전한 러스트에서 다른 점은 이런 것들이 가능하다는 것뿐입니다:
- 생 포인터 역참조하기
unsafe
함수 호출하기 (C 함수나, 컴파일러 내부, 그리고 할당자를 직접 호출하는 것 포함)unsafe
트레잇 구현하기- 가변 정적 변수를 접근하거나 수정하기
union
의 필드를 접근하기
이게 전부입니다. 이런 연산들이 불안전의 영역으로 추방된 이유는, 이것들 중 하나라도 잘못 사용할 경우 그토록 두렵던 미정의 동작을 일으키기 때문입니다. 미정의 동작을 일으키면 컴파일러가 당신의 프로그램에 임의의 나쁜 짓들을 할 수 있는 모든 권리를 얻게 됩니다. 당연하게도 미정의 동작은 일으켜서는 안됩니다.
C와 다르게, 미정의 동작은 러스트에서는 꽤 제한되어 있습니다. 러스트의 코어 언어가 막으려고 하는 것들은 이런 것들입니다:
- 달랑거리거나 정렬되어 있지 않은 포인터를 역참조하는 것 (
*
연산자를 사용해서) (밑 참조) - 레퍼런스 규칙 을 어기는 것
- 잘못된 호출 ABI를 이용해 함수를 호출하거나 잘못된 되감기 ABI를 가지고 있는 함수에서 되감는 것
- 데이터 경합 을 일으키는 것
- 지금 실행하는 스레드가 지원하지 않는 타겟 기능들 로 컴파일된 코드를 실행하는 것
- 잘못된 값을 생산하는 것 (혼자서나
enum
/struct
/배열/튜플과 같은 복합 타입의 필드로써나):- 0도 1도 아닌
bool
- 유효하지 않은 식별자1를 사용하는
enum
- 널
fn
포인터 - [0x0, 0xD7FF] 와 [0xE000, 0x10FFFF] 범위를 벗어나는
char
!
타입의 값 (이 타입의 모든 값은 유효하지 않습니다)- 초기화되지 않은 메모리 로부터 읽어들인 정수 (
i*
/u*
), 부동소수점 값 (f*
), 혹은 생 포인터, 혹은str
안의 초기화되지 않은 메모리. - 달랑거리거나, 정렬되지 않았거나, 유효하지 않은 값을 가리키는 레퍼런스/
Box
- 잘못된 메타데이터를 가지고 있는 넓은 레퍼런스,
Box
, 혹은 생 포인터:dyn Trait
메타데이터는 그것이Trait
의 vtable을 가리키는 포인터가 아닐 경우 유효하지 않습니다- 슬라이스 메타데이터는 길이가 올바른
usize
가 아니면 유효하지 않습니다 (즉, 초기화되지 않은 메모리에서 읽어들이면 안됩니다)
- 널인
NonNull
같은, 커스텀으로 잘못된 값이 들어있는 타입 (커스텀으로 잘못된 값을 요청하는 것은 불안정한 기능이지만NonNull
같은, 몇 가지 안정 버전의 표준 라이브러리 타입들은 이것을 사용합니다.)
- 0도 1도 아닌
"미정의 동작"에 관해 더 자세한 설명이 필요하다면 참조서 를 참고하셔도 됩니다.
값을 "생산하는" 일은 값이 할당되거나, 함수/기본 연산에 전달되거나, 함수/기본 연산에서 반환될 때 일어납니다.
레퍼런스/포인터가 "달랑거린다"는 것은 그것이 널이거나 그것이 가리키는 바이트가 모두 같은 할당처에 있는 것이 아니라는 뜻입니다 (그 바이트들은 모두 어떤 할당처에는 있어야 합니다).
그것이 가리키는 바이트들의 너비는 포인터 값과 참조되는 타입의 크기에 따라 결정됩니다. 따라서 만약 너비가 비어 있다면, "달랑거리는" 것은 "널"인 것과 같습니다. 슬라이스와 문자열은 그들의 전체 범위를 가리킨다는 것을 유의한다면,
길이 메타데이터가 너무 크지 않도록 하는 것이 중요해집니다 (특히, 할당량과 그에 따른 슬라이스와 문자열은 isize::MAX
바이트보다 클 수 없습니다). 만약 어떤 이유로 이것이 거추장스럽다면, 생 포인터를 쓰는 것을 고려해 보세요.
그게 전부입니다. 그것이 러스트에 있는 미정의 동작의 모든 원인입니다. 물론 불안전한 함수들과 트레잇들은 프로그램이 지켜야 하는 임의의 다른 제약들을 걸 수 있고, 그것을 어기면 미정의 동작이 일어나겠죠. 예를 들어, 할당자 API는 할당되지 않은 메모리를 해제하는 것은 미정의 동작이라고 정의합니다.
그러나 이런 제약들을 어기면 결국 위의 문제들 중 하나로 이어지게 될 것입니다. 어떤 추가적인 제약들은 컴파일러 내부가 코드를 최적화하는 과정에서 하는 특별한 가정들에서부터 비롯될 수도 있습니다.
예를 들어, Vec
과 Box
는 그들의 포인터가 항상 널이 아니도록 하는 내부 코드를 사용합니다.
러스트는 이 외의 다른 애매한 작업들에는 꽤나 관대합니다. 러스트는 이런 작업들을 "안전하다"고 판단합니다:
- 데드락 (교착 상태)
- 경합 조건 이 있는 것
- 메모리 누설
- (
+
등의 기본 연산자를 이용한) 정수 오버플로우 - 프로그램 비정상적 종료
- 프로덕션 데이터베이스 삭제하기
더 자세한 정보는 참조서 를 참고하세요.
하지만 어떤 프로그램이 이런 것을 한다면 아마도 잘못된 것일 겁니다. 러스트는 이런 것들이 드물게 일어나게 하기 위해 많은 도구들을 제공하지만, 이런 종류의 문제를 아예 막기에는 비현실적이라고 판단됩니다.
"식별자(discriminant)"는 열거형의 각각의 형(variant)에 대응하는 정수입니다. 보통 0부터 시작합니다.
불안전함과 함께 일하는 것
러스트는 일반적으로 우리가 불안전한 러스트와 이야기할 때 정해진 범위 안에서, 이진수의 방식으로 이야기하는 도구만 줍니다. 불행하게도 현실은 그것보다 훨씬 복잡하죠. 예를 들어, 다음의 간단한 함수를 생각해 볼까요:
#![allow(unused)] fn main() { fn index(idx: usize, arr: &[u8]) -> Option<u8> { if idx < arr.len() { unsafe { Some(*arr.get_unchecked(idx)) } } else { None } } }
이 함수는 안전하고 올바릅니다. 우리는 인덱스가 범위 안에 있는지 확인하고, 그렇다면 더 이상 확인하지 않고 배열을 바로 인덱싱합니다. 이렇게 잘 구현된 불안전한 함수를 건전하다 고 하는데, 이것은 안전한 코드가 이 코드를 악용해서 미정의 동작을 유발할 수 없다는 뜻입니다 (이건 바로 안전한 러스트의 유일한 근본적 특성이었죠).
하지만 이런 흔한 함수 안에서도, 불안전한 코드 블럭의 범위는 모호합니다. 여기서 <
를 <=
로 바꾸는 경우를 생각해 보세요:
#![allow(unused)] fn main() { fn index(idx: usize, arr: &[u8]) -> Option<u8> { if idx <= arr.len() { unsafe { Some(*arr.get_unchecked(idx)) } } else { None } } }
이 프로그램은 이제 불건전하고, 안전한 러스트는 미정의 동작을 유발할 수 있지만, 우리는 안전한 코드만 수정했을 뿐입니다. 이것이 바로 안전함의 근본적인 문제입니다: 지역에 한정되어 있지 않죠. 이 불안전한 연산의 건전성은 어쩔 수 없이 다른 "안전한" 연산들이 만든 상태에 의존하게 됩니다.
불안전함을 도입하는 것이 임의의 다른 종류의 유해함을 고려하지 않아도 되는 점에서, 안전성은 경계가 있습니다. 예를 들어, 슬라이스에 확인되지 않은 인덱싱을 하는 것은 갑자기 슬라이스가 널이 되거나 초기화되지 않은 메모리를 포함하게 되는 경우를 걱정해야 하는 것은 아닙니다. 근본적으로 변하는 것은 없습니다. 하지만 프로그램이 본질적으로 상태를 가지게 되는 것과 당신의 불안전한 작업들이 임의의 다른 상태에 의존할 수 있게 된다는 점에서, 안전성은 분리되어 있지는 않습니다.
이런 지역에 한정되지 않는 특성은 우리가 실제로 지속되는 상태를 포함하게 되면 더욱 심해집니다. 다음의 간단한 Vec
구현을 생각해 보세요:
use std::ptr; // 주의: 이 정의는 순진합니다. Vec을 구현하는 챕터를 참고하세요. pub struct Vec<T> { ptr: *mut T, len: usize, cap: usize, } // 이 구현은 영량 타입을 제대로 다루지 못하니 주의하세요. // Vec을 구현하는 챕터를 보세요. impl<T> Vec<T> { pub fn push(&mut self, elem: T) { if self.len == self.cap { // 이 예제에는 중요하지 않은 부분입니다 self.reallocate(); } unsafe { ptr::write(self.ptr.add(self.len), elem); self.len += 1; } } fn reallocate(&mut self) { } } fn main() {}
이 코드는 합리적으로 검사하고 편하게 확인할 수 있을 만큼 간단합니다. 이제 이런 메서드를 추가하는 것을 생각해 보세요:
fn make_room(&mut self) {
// 용량을 늘립니다
self.cap += 1;
}
이 코드는 100% 안전한 러스트이지만, 동시에 완전히 불건전합니다. 용량을 변경하는 것은 Vec
의 불문율(cap
이 Vec
의 할당된 용량을 반영한다는 것)을 파괴합니다. 이것은 Vec
의 나머지 코드가 방어할 수 있는 것이 아닙니다. Vec
은 cap
필드를 검증할 방법이 없기 때문에 믿는 수밖에 없습니다.
이 unsafe
코드는 구조체 필드의 불문율에 의존하기 때문에, 한 함수 전체를 오염시키는 것보다 더한 짓을 합니다: 한 모듈 전체를 오염시키죠. 일반적으로 불안전한 코드의 범위를 제한하는, 오류 없는 유일한 방법은 모듈 경계에서 private
를 이용하는 것입니다.
그렇지만 이 코드는 완벽하게 동작합니다. make_room
의 존재는, 우리가 그것을 pub
으로 표시하지 않았기 때문에, Vec의 건전성에 문제가 되지 않습니다. 이 함수를 정의한 모듈만 이것을 호출할 수 있기 때문입니다.
또, make_room
은 Vec
의 private
필드를 직접적으로 접근하므로, Vec
과 같은 모듈에서 작성될 수밖에 없습니다.
그러므로 우리는 복잡한 불문율에 의지하는, 완전히 안전한 추상화를 작성할 수 있게 됩니다. 이것은 안전한 러스트와 불안전한 러스트 사이의 관계에 있어서 필수적입니다.
우리는 이미 불안전한 러스트가 일부의 안전한 코드를 믿어야 하지만, 일반적인 안전한 코드는 믿으면 안된다는 것을 보았습니다. 공개 상태(pub
을 이용한)도 비슷한 이유로 불안전한 코드에 있어서 중요합니다:
우리가 믿고 있는 상태를 망가트리지 않을 거라고 믿으며 온 우주에 있는 모든 안전한 코드를 의지할 필요가 없으니까요.
안전함은 살았습니다!
러스트에서의 데이터 표현
저수준 프로그래밍은 데이터 레이아웃에 많은 신경을 씁니다. 그것은 중요하거든요. 이것은 또한 언어의 전반적인 부분에 영향을 주기 때문에, 우리는 러스트에서 데이터가 어떻게 표현되는지를 파헤쳐 보면서 시작하겠습니다.
이 챕터는 이상적으로는 참조서의 타입 레이아웃 섹션과 동의하는 내용이고, 중복으로 이 책에 표시되었습니다. 이 책이 처음 쓰여질 때 참조서는 완전히 황폐한 상태였고, 러스토노미콘은 참조서에 대한 부분적인 대안으로 제공하려고 시도했습니다. 이제 참조서는 그렇지 않으므로, 이 챕터 전체는 이상적으로는 삭제되어도 될 겁니다.
우리는 이 챕터를 조금 더 놔둘 거지만, 이상적으로는 새로운 사실이나 개선점을 기여하고 싶다면 참조서에 대신 기여해 주세요.
(번역자: 현재 러스트 참조서는 한국어로 번역되지 않은 상태이므로, 한국어 노미콘에 우선 기여해주시면 감사하겠습니다!)
repr(Rust)
첫번째로 그리고 가장 중요하게도, 모든 타입은 바이트로 표시되는 정렬선이 있습니다. 타입의 정렬선은 값을 어떤 주소에 저장하는 게 유효한지를 특정해 줍니다. n
의 정렬선을 가지고 있는 값은 n
의 배수인 주소에만 저장할 수 있습니다.
따라서 정렬선이 2이면 짝수인 주소에 저장되어야 한다는 뜻이고, 1이라면 어디든지 저장될 수 있다는 뜻입니다. 정렬선은 최소 1이고, 항상 2의 거듭제곱입니다.
기본 타입들은 그들의 크기에 맞춰 정렬됩니다. 플랫폼에 따라 다르긴 하지만요. 예를 들어, x86에서는 u64
와 f64
는 보통 4바이트(32비트)마다 정렬됩니다.
타입의 크기는 항상 정렬선의 배수여야 합니다 (0은 어떤 정렬선이든 인정되는 크기입니다). 이것은 그 타입의 배열이 언제나 그 크기의 배수로 인덱싱되는 것을 보장합니다. 동량 타입의 경우에는 타입의 크기와 정렬선이 컴파일할 때 모를 수도 있다는 것을 주의하세요.
러스트는 복잡한 데이터를 다음의 방법으로 가지런하게 놓을 수 있게 해 줍니다:
- 구조체 (곱 타입이라고 부름)
- 튜플 (이름 없는 곱 타입)
- 배열 (동형의 곱 타입)
- 열거형 (합 타입 또는 태그가 있는 공용체라고 부름)
- 공용체 (태그 없는 공용체)
열거형은 그 형(形)이 모두 연관된 데이터가 없으면 필드가 없다고 합니다.
기본적으로, 복합적인 자료구조는 그 필드들의 정렬선들 중 최댓값을 정렬선으로 갖습니다. 러스트는 따라서 필요한 곳에 여백을 넣음으로써 모든 필드가 잘 정렬되고, 타입의 총 크기가 그 정렬선의 배수가 되도록 합니다. 예를 들어 다음의 구조체는:
#![allow(unused)] fn main() { struct A { a: u8, b: u32, c: u16, } }
이런 기본 타입들을 그들의 해당되는 크기로 정렬하는 타겟 플랫폼에서 32비트로 정렬될 것입니다. 따라서 구조체 전체는 32비트의 배수를 크기로 가지게 될 겁니다. 이 구조체는 이렇게 될 수도 있습니다:
#![allow(unused)] fn main() { struct A { a: u8, _pad1: [u8; 3], // `b`를 정렬하기 위해서입니다 b: u32, c: u16, _pad2: [u8; 2], // 전체 크기가 4바이트의 배수가 되게 하기 위해서입니다 } }
아니면 이렇게도요:
#![allow(unused)] fn main() { struct A { b: u32, c: u16, a: u8, _pad: u8, } }
이런 타입들에는 간접적인 조치는 없습니다; 모든 데이터는 C에서 그럴 것 같이, 구조체 안에 저장됩니다. 그러나 배열은 예외인데 (순서대로, 그리고 밀집되어 할당되어 있으니까요), 기본적으로 데이터의 정렬선은 특정되지 않습니다. 다음의 두 구조체 정의를 볼 때:
#![allow(unused)] fn main() { struct A { a: i32, b: u64, } struct B { a: i32, b: u64, } }
러스트는 A
타입의 두 값은 그 데이터가 정확히 똑같은 식으로 정렬될 것은 보장합니다. 그러나 러스트는, 현재로써는, A
타입의 값이 B
타입의 값과 같은 필드 순서나 여백을 가질지는 보장하지 않습니다.
A
와 B
가 이렇게 적혔으니 학술적인 느낌일 것 같지만, 러스트의 몇 가지 다른 기능들이 데이터 정렬을 여러 가지 복잡한 방법으로 가지고 놀기 좋게 해 줍니다.
예를 들어, 이 구조체를 생각해 보세요:
#![allow(unused)] fn main() { struct Foo<T, U> { count: u16, data1: T, data2: U, } }
이제 이 구조체의 한 버전인 Foo<u32, u16>
와 Foo<u16, u32>
를 생각해 보세요. 만약 러스트가 특정된 순서로 필드를 배치한다면, 그 정렬선 문제를 해결하기 위해 구조체 안에 여백의 값을 집어넣기를 우리는 기대할 것입니다.
따라서 만약 러스트가 필드를 재정렬하지 않았다면, 이런 식으로 출력이 나오겠지요:
struct Foo<u16, u32> {
count: u16,
data1: u16,
data2: u32,
}
struct Foo<u32, u16> {
count: u16,
_pad1: u16,
data1: u32,
data2: u16,
_pad2: u16,
}
후자의 경우는 꽤나 공간을 낭비합니다. 공간 사용을 최적화하려면 일반화의 다른 버전마다 다른 필드 순서가 필요하겠군요.
열거형은 이런 고민을 더욱 복잡하게 만듭니다. 순진하게 보자면, 이런 열거형은:
#![allow(unused)] fn main() { enum Foo { A(u32), B(u64), C(u8), } }
이렇게 배치될 수 있겠습니다:
#![allow(unused)] fn main() { struct FooRepr { data: u64, // 이것은 `tag`에 따라 u64, u32, 또는 u8 입니다. tag: u8, // 0 = A, 1 = B, 2 = C } }
그리고 이것은 정말로 실제와 비슷하게 배치되는 것입니다 (tag
의 위치와 크기에 따라).
하지만 어떤 몇 가지 경우에서는 이런 표현은 비효율적입니다. 전통적인 경우는 러스트의 "널 포인터 최적화"입니다:
하나의 ()
을 가진 형(예를 들어 None
)과 (중첩될 수도 있는) 널이 될 수 없는 포인터를 가진 형(예를 들어 Some(&T)
)이 있는 열거형은 태그가 필요하지 않습니다. 널 포인터는 안전하게 ()
형(None
)으로 해석할 수 있거든요.
최종적인 결과는, 예를 들어, 이렇게 됩니다: size_of::<Option<&T>>() == size_of::<&T>()
러스트에는 널이 될 수 없는 타입이나, 이를 포함하는 타입들이 많이 있는데, Box<T>
, Vec<T>
, String
, &T
, 그리고 &mut T
같은 것들입니다.
비슷하게, 중첩된 열거형들이 태그를 하나의 식별자로 뭉치는 경우도 생각할 수 있는데, 그것은 그들이 정의에 의해서 유효한 값의 범위가 정해져 있기 때문입니다.
원칙상 열거형은 꽤나 정교한 알고리즘을 써서 중첩된 타입에 있는 비트들을 금지된 값들과 함께 저장할 수 있습니다. 따라서 오늘날 우리는 열거형의 배치 상태를 밝혀지지 않은 상태로 놔두는 것이 특별히 좋습니다.
이량(異量) 타입
거의 항상, 우리는 타입이 정적으로 알려져 있고 양수의 크기를 가지고 있다고 생각합니다. 러스트에서 이것은 항상 그렇지는 않습니다.
동량(動量) 타입 (DST)
러스트는 동량(動量) 타입(DST)을 지원합니다: 정적으로 알려진 크기나 정렬선이 없는 타입을 말이죠. 표면적으로는 이것은 좀 말이 되지 않습니다: 러스트는 무언가와 올바르게 작업하기 위해서는 그것의 크기와 정렬선을 알아야 하거든요! 이런 면에서 DST는 보통의 타입이 아닙니다. 정적으로 알려진 크기가 없기 때문에, 이런 타입들은 포인터 뒤에서만 존재할 수 있습니다. 따라서 DST를 가리키는 포인터는 포인터와 DST를 "완성하는" 정보로 이루어진 넓은 포인터가 됩니다 (밑에서 더 설명합니다).
언어에서 보이는 주요한 DST는 두 가지가 있습니다:
트레잇 객체는 그것이 특정하는 트레잇을 구현하는 어떤 타입을 표현합니다. 정확한 원래 타입은 런타임 리플렉션을 위해 지워지고, 타입을 쓰기 위해 필요한 모든 정보를 담고 있는 vtable로 대체됩니다. 트레잇 객체를 완성하는 정보는 이 vtable의 포인터입니다. 포인터가 가리키는 대상의 런타임 크기는 vtable에서 동적으로 요청될 수 있습니다.
슬라이스는 어떤 연속적인 저장소에 대한 뷰일 뿐입니다 -- 보통 이 저장소는 배열이거나 Vec
입니다. 슬라이스 포인터를 완성시키는 정보는 가리키고 있는 원소들의 갯수입니다.
가리키는 대상의 런타임 크기는 그냥 한 원소의 정적으로 알려진 크기와 원소들의 갯수를 곱한 것입니다.
구조체는 사실 마지막 필드로써 하나의 동량 타입을 직접 저장할 수 있지만, 그러면 그들 자신도 동량 타입이 됩니다:
#![allow(unused)] fn main() { // 직접적으로 스택에 저장할 수 없음 struct MySuperSlice { info: u32, data: [u8], } }
이런 타입은 생성할 방법이 없으면 별로 쓸모가 없지만 말이죠. 현재 유일하게 제대로 지원되는, 커스텀 동량 타입을 만들 방법은 타입을 제네릭으로 만들고 크기 강제 망각을 실행하는 것입니다:
struct MySuperSliceable<T: ?Sized> { info: u32, data: T, } fn main() { let sized: MySuperSliceable<[u8; 8]> = MySuperSliceable { info: 17, data: [0; 8], }; let dynamic: &MySuperSliceable<[u8]> = &sized; // 출력: "17 [0, 0, 0, 0, 0, 0, 0, 0]" println!("{} {:?}", dynamic.info, &dynamic.data); }
(네, 커스텀 동량 타입은 지금으로써는 매우 설익은 기능입니다.)
영량(無量) 타입 (ZST)
러스트는 타입이 공간을 차지하지 않는다고 말하는 것도 허용합니다:
#![allow(unused)] fn main() { struct Nothing; // 필드 없음 = 크기 없음 // 모든 필드가 크기 없음 = 크기 없음 struct LotsOfNothing { foo: Nothing, qux: (), // 빈 튜플은 크기가 없습니다 baz: [u8; 0], // 빈 배열은 크기가 없습니다 } }
영량(無量) 타입(ZST)은, 당연하게도 그 자체로는, 별로 쓸모가 없습니다. 하지만 러스트의 많은 기이한 레이아웃 선택들이 그렇듯이, 그들의 잠재력은 일반적인 환경에서 빛나게 됩니다: 러스트는 영량 타입의 값을 생성하거나 저장하는 모든 작업이 아무 작업도 하지 않는 것과 같을 수 있다는 사실을 매우 이해하거든요. 일단 값을 저장한다는 것부터가 말이 안됩니다 -- 차지하는 공간도 없는걸요. 또 그 타입의 값은 오직 하나이므로, 어떤 값이 읽히든 그냥 무에서 값을 만들어내면 됩니다 -- 이것 또한 차지하는 공간이 없기 때문에, 아무것도 하지 않는 것과 같습니다.
이것의 가장 극단적인 예시 중 하나가 Map
과 Set
입니다. Map<Key, Value>
가 주어졌을 때, Set<Key>
를 Map<Key, UselessJunk>
를 적당히 감싸는 자료구조로 만드는 것은 흔하게 볼 수 있습니다.
많은 언어들에서 이것은 UselessJunk
타입을 위한 공간을 할당하고, UselessJunk
를 가지고 아무것도 하지 않기 위해서 그 값을 저장하고 읽는 작업을 강제할 겁니다.
이 작업이 불필요하다는 것을 증명하려면 컴파일러는 복잡한 분석을 해야 할 겁니다.
그러나 러스트에서는 우리는 그냥 Set<Key> = Map<Key, ()>
라고 말할 수 있습니다. 이제 러스트는 컴파일할 때 모든 메모리 읽기와 저장은 의미가 없고, 저장 공간을 할당할 필요도 없다는 것을 알게 됩니다.
결과적으로 나오는 코드는 그냥 HashSet
의 커스텀 구현일 뿐이고, HashMap
이 값을 처리할 때의 연산은 존재하지 않게 됩니다.
안전한 코드는 영량 타입에 대해서 걱정하지 않아도 되지만, 불안전한 코드는 크기가 없는 타입의 중요성을 신경써야 합니다. 특히 포인터 오프셋은 아무 작업도 하지 않는 것과 같고, 할당자는 보통 0이 아닌 크기를 요구합니다.
영량 타입을 가리키는 레퍼런스(빈 슬라이스 포함)는 다른 레퍼런스와 다르게, 널이어도 되지만 잘 정렬되어 있어야 합니다. 영량 타입을 가리키지만 정렬되지 않은 포인터를 역참조하는 것 역시, 다른 타입들과 마찬가지로 미정의 동작입니다.
빈 타입
러스트는 또한 그 타입의 값을 만들 수조차 없는 타입을 정의하는 것도 지원합니다. 이런 타입들은 타입 측면에서만 말할 수 있고, 값 측면에서는 절대 말할 수 없습니다. 빈 타입은 형이 없는 열거형을 정의함으로써 만들 수 있습니다:
#![allow(unused)] fn main() { enum Void {} // 형 없음 = 비어 있음 }
빈 타입은 영량 타입보다도 더 작습니다. 빈 타입의 예시로 들 만한 것은 타입 측면에서의 접근불가성입니다. 예를 들어, 어떤 API가 일반적으로 Result
를 반환해야 하지만, 어떤 경우에서는 실패할 수 없다고 합시다.
우리는 이것을 Result<T, Void>
를 반환함으로써 타입 레벨에서 소통할 수 있습니다.
API의 사용자들은 이런 Result
의 값이 Err
가 되기에 정적으로 불가능하다는 것을 알고 자신 있게 unwrap
할 수 있을 겁니다, 왜냐하면 Err
값이 있으려면 Void
타입의 값이 생산되어야 하거든요.
원칙적으로는 러스트가 이런 사실에 기반하여 몇 가지 흥미로운 분석과 최적화를 수행할 수 있을 겁니다. 예를 들어 Result<T, Void>
는 Err
형이 실제로 존재하지 않기에, 그냥 T
로 표현할 수 있겠죠
(엄격하게 말하면, 이것은 보장되지 않은 최적화일 뿐이고, T
와 Result<T, Void>
중 하나를 다른 하나로 변질시키는 것은 아직 미정의 동작입니다).
다음의 코드도 컴파일 됩니다:
#![allow(unused)] fn main() { enum Void {} let res: Result<u32, Void> = Ok(0); // Err 형이 존재하지 않으므로, Ok 형은 사실 패턴 매칭이 실패할 수 없습니다. let Ok(num) = res; }
빈 타입에 대한 마지막 하나의 조그만 사실은, 빈 타입을 가리키는 생 포인터는 놀랍게도 유효하게 생성할 수 있지만, 그것을 역참조하는 것은 말이 안되기 때문에 미정의 동작이라는 것입니다.
우리는 C의 void*
타입을 *const Void
로 설계하는 것을 추천하지 않습니다. 많은 사람들이 이렇게 했지만 얼마 지나지 않아 문제에 부딪혔는데, 러스트는 불안전한 코드로 빈 타입의 값을 만드려고 하는 것을 막는 안전 장치가 없고,
만약 빈 타입의 값을 만들면, 그것은 미정의 동작이기 때문입니다. 이것은 특별히 문제가 되었는데, 개발자들이 생 포인터를 레퍼런스로 바꾸는 습관이 있었고 &Void
값을 만드는 것 역시 미정의 동작이기 때문입니다.
*const ()
(혹은 비슷한 타입)은 void*
에 대응해서 무리 없이 잘 동작하고, 안전성의 문제 없이 레퍼런스로 만들 수 있습니다.
값을 읽고 쓰려고 하는 시도를 막는 것은 여전히 하지 않지만, 최소한 미정의 동작보다는 아무 작업도 하지 않는 것으로 컴파일됩니다.
외래 타입
외래 타입으로 불리는, 알 수 없는 크기의 타입을 추가하여 러스트 개발자들이 C의 void*
나 다른 "선언되었지만 정의되지 않은" 타입들을 좀더 정확하게 설계하자는, 승인된 RFC가 있습니다.
하지만 러스트 2018 기준으로, 이 기능은 size_of_val::<MyExternType>()
이 어떻게 작동해야 하는지에 걸려서 대기 상태에 갇혀 있습니다.
다른 데이터 표현들
러스트는 기본으로부터 다른 데이터 설계 전략을 구성하게 해 줍니다.
불안전 코드 가이드라인도 있습니다 (비표준이니 주의하세요).
repr(C)
이것은 가장 중요한 repr
입니다. 이것은 매우 간단한 의도를 가지고 있습니다: C가 하는대로 하라는 것이죠. 필드들의 정렬 순서, 크기, 정렬선은 C나 C++에서 되는 것 같이 될 겁니다.
어떤 타입이든 FFI 경계를 넘겨 보내려면 repr(C)
로 표현되어야 하는데, 이는 C가 프로그래밍 세계의 공용어이기 때문입니다. 이것은 값을 다른 타입으로 재해석하는 것과 같은, 데이터 레이아웃을 가지고 정교한 장난을 수월하게 칠 수 있기 위해서 필수적입니다.
우리는 rust-bindgen과 cbindgen를 둘 다, 혹은 둘 중 하나를 써서 당신 대신 FFI 경계를 관리하기를 매우 권장합니다. 러스트 팀은 이 프로젝트들과 긴밀하게 작업하여 이들이 튼튼하게 작동하고,
타입 레이아웃과 repr
들에 대한 현재와 미래의 보장에 잘 맞도록 신경쓰고 있습니다.
repr(C)
와 러스트의 (C보다) 이상한 데이터 설계 기능의 상호작용은 주의해야 합니다. "FFI를 위한" 것과 "데이터 표현을 바꾸기" 위한 두 가지 목적이 동시에 있기 때문에, repr(C)
는 FFI 경계로 보내면 말이 안되거나 문제가 생길 수 있는 타입들에 적용할 수 있습니다.
-
영량 타입(ZST)은 그대로 크기가 0으로 되는데, 이것은 C에서 표준 동작이 아니고, C++에서 빈 타입의 동작과 분명하게 반대되는데, C++에서는 빈 타입이라도 한 바이트의 공간을 차지해야 한다고 말하기 때문입니다.
-
동량 타입(DST)의 포인터(넓은 포인터)와 튜플은 C에서 없는 개념이므로, FFI로 보내면 절대 안전하지 않습니다.
-
필드가 있는 열거형 또한 C와 C++에서 없는 개념이지만, 타입 사이의 유효한 변환이 정의되어 있습니다.
-
만약
T
가 FFI로 보내도 안전하고 널이 아닌 포인터 타입이라면,Option<T>
는T
와 같은 데이터 표현과 ABI를 갖추고, 따라서 FFI로 보내도 안전하다는 것이 보장됩니다. 이 글을 쓰는 시점에서, 이것은&
,&mut
, 그리고 함수 포인터들에 해당하는데, 이것들은 전부 널이 될 수 없기 때문입니다. -
튜플 구조체는
repr(C)
에서는 일반 구조체와 같은데, 일반 구조체와 다른 점은 필드의 이름이 없다는 것뿐이기 때문입니다. -
필드가 없는 열거형에 있어서는
repr(C)
는repr(u*)
(다음 섹션을 보세요) 중 하나와 같습니다. 여기서 선택되는 바이트 크기는 타겟 플랫폼의 C 애플리케이션 이진 인터페이스(ABI)에서의 기본 열거형 크기입니다. 주의할 점은 C에서의 데이터 표현은 구현에 따라 다르게 정의되어 있으므로, 이것은 "최선의 추측"이라는 점입니다. 특별히, 관련된 C 코드가 특정한 플래그로 컴파일되면 이 설명이 맞지 않을 수도 있습니다. -
repr(C)
나repr(u*)
로 표현되는 필드 없는 열거형은, C나 C++에서는 허용되는 동작이지만, 그래도 대응하는 형이 없는 정수 값으로 설정하면 안됩니다. 열거형의 형이 대응하지 않는 열거형의 값을 (불안전하게) 만들어내는 것은 미정의 동작입니다. (이렇게 함으로써 패턴 완전 매칭이 잘 작성되고 컴파일되게 됩니다.)
repr(transparent)
#[repr(transparent)]
은 크기가 0이 아닌 하나의 필드를 가지고 있는 (영량 필드는 더 있어도 됩니다) 구조체나 형이 하나인 열거형에만 쓰일 수 있습니다.
이것의 효과는 구조체/열거형의 전체 레이아웃과 ABI가 그 하나의 필드와 완전히 동일하도록 보장된다는 것입니다.
주의:
repr(transparent)
를 공용체에 적용하는transparent_unions
nightly 기능이 있지만, 디자인 문제 때문에 표준화되지 않았습니다. 더 자세한 내용은 이슈를 참고하세요.
이것의 목표는 구조체/열거형과 그의 유일한 필드 간에 변환을 가능하게 하는 것입니다. 이것의 한 예는 UnsafeCell
인데, 이것은 자신이 감싸고 있는 타입으로 변환될 수 있습니다 (UnsafeCell
은 또한 불안정한 no_niche를 쓰기 때문에, 이것의 ABI는 다른 타입 속에 들어갔을 때에 동일하다고 보장되지는 않습니다).
또, 그 유일한 필드가 FFI로 보내도 괜찮은 타입이라면, 그 유일한 필드가 있는 구조체/열거형을 FFI로 보내는 작업도 잘 작동하는 것이 보장됩니다. 예를 들어, 이것은 struct Foo(f32)
나 enum Foo { Bar(f32) }
가 f32
와 항상 동일한 ABI를 가지게 하도록 하기 위해서 꼭 필요합니다.
이 표현 방식은 타입의 유일한 필드가 pub
이거나, 그 레이아웃이 단순하게 묘사되었을 경우에만 공개 ABI의 한 부분으로 인정됩니다. 그렇지 않다면 이 레이아웃은 다른 크레이트들이 의존해서는 안됩니다.
더 자세한 내용은 RFC 1758과 RFC 2645에 있습니다.
repr(u*), repr(i*)
이것들은 필드 없는 열거형을 만들기 위한 크기와 부호를 지정합니다. 식별자가 이 정수를 벗어나서 오버플로우되면 컴파일할 때 에러가 발생할 겁니다. 하지만 이것을 러스트에서 허용할 수도 있습니다: 오버플로우된 순간 0이 되게 명시적으로 말하는 것이죠. 그러나 러스트는 열거형의 두 개의 형이 같은 식별자를 가지는 것을 허용하지는 않을 겁니다.
"필드 없는 열거형"이라는 용어는 단지 열거형이 그 형에서 데이터를 가지지 않는다는 것을 말합니다. repr(u*)
나 repr(C)
가 없는 필드 없는 열거형은 여전히 러스트 타입이고, 안정적인 ABI 표현이 존재하지 않습니다.
repr
을 더하는 것은 ABI를 위해 이 열거형이 지정된 타입의 정수로 다뤄지게 합니다.
만약 열거형이 필드가 있다면, 타입의 정해진 레이아웃이 있다는 점에서 효과는 repr(C)
의 효과와 비슷하게 됩니다. 이것은 열거형을 C 코드에 넘기고, 그 타입의 실제 표현을 접근하고 직접 그 태그와 필드를 조작할 수 있게 해 줍니다.
자세한 내용은 RFC를 참고하세요.
이 repr
은 구조체에는 아무 효과도 없습니다.
필드가 있는 열거형에 명시적인 repr(u*)
, repr(i*)
, 혹은 repr(C)
를 추가하는 것은 널 포인터 최적화를 억제하는데, 이것은 다음과 같습니다:
#![allow(unused)] fn main() { use std::mem::size_of; enum MyOption<T> { Some(T), None, } #[repr(u8)] enum MyReprOption<T> { Some(T), None, } assert_eq!(8, size_of::<MyOption<&u16>>()); assert_eq!(16, size_of::<MyReprOption<&u16>>()); }
이 최적화는 필드가 없는 열거형에 명시적으로 repr(u*)
, repr(i*)
, 혹은 repr(C)
가 적용된 경우에는 여전히 작동합니다.
repr(packed)
repr(packed)
는 타입에 어떤 여백도 제거하고, 한 바이트에 정렬하도록 러스트에 강제합니다. 이것은 메모리 사용량은 줄여주지만, 아마 다른 부작용들을 초래할 것입니다.
더 자세히 말하자면, 대부분의 아키텍쳐는 값들이 제대로 정렬되는 것을 매우 선호합니다. 이것은 제대로 정렬되지 않은 읽기는 뒤로 미뤄지거나 (x86), 심지어는 강제종료됩니다 (일부의 ARM 칩들). 이렇게 "압축"된 필드를 직접 읽거나 저장하는 간단한 경우들에서는, 컴파일러가 쉬프트나 마스크 같은 것들로 정렬 문제를 간단히 수정할 수 있을지도 모릅니다. 그러나 그 압축된 필드에 레퍼런스를 단다면, 컴파일러가 정렬되지 않은 읽기를 피할 수 있는 코드를 낼 수 있을 것 같지는 않습니다.
이것이 미정의 동작을 일으킬 수 있기 때문에, 린트가 구현되었고 지금은 아주 심각한 오류가 될 것입니다.
repr(packed)
는 가볍게 사용되어서는 안됩니다. 극심하게 이것이 필요하지 않다면 이것은 쓰여져서는 안됩니다.
이 표현은 repr(C)
나 repr(Rust)
와 함께 쓸 수 있습니다.
repr(align(n))
repr(align(n))
은 (n
은 2의 거듭제곱입니다) 타입이 최소 n의 정렬선을 가지도록 요구합니다.
이것은 여러 가지 장난을 가능하게 해 주는데, 예를 들어 배열의 이웃하는 원소들이 서로의 캐시 선을 절대 공유하지 않도록 하는 것이 있습니다 (이것으로 어떤 종류의 동시성 코드는 빠르게 할 수 있습니다).
이 표현은 repr(C)
나 repr(Rust)
와 함께 쓸 수 있지만, repr(packed)
와는 함께 쓸 수 없습니다.
소유권과 수명
소유권은 러스트의 혁신적인 기능입니다. 이것은 러스트가 쓰레기 수집을 피하면서, 완전히 메모리 안전과 효율성을 챙길 수 있게 해 줍니다. 소유권 시스템을 깊게 살펴보기 전에, 이런 설계의 동기를 생각해 보겠습니다.
우리는 당신이 쓰레기 수집(GC)이 항상 최선의 해결책은 아니고, 어떤 상황에서는 메모리를 수동으로 관리하는 것이 낫다는 것을 수긍한다고 가정하겠습니다. 만약 이것을 수긍하지 않는다면, 다른 언어를 찾아보시는 건 어떨까요?
GC에 대한 당신의 생각과 상관없이, 코드를 안전하게 만드는 것은 꽤나 확실하게 엄청난 축복입니다. 절대로 값들이 너무 빨리 없어지는 것을 걱정하지 않아도 되니까요 (그래도 그 값을 가리키고 싶었는지 여부는 조금 다른 문제이지만 말이죠...). 이것은 C와 C++ 프로그램들이 해결해야 하는, 프로그램 어디에서나 볼 수 있는 문제입니다. 비 GC 언어를 사용해본 사람들이라면 한번쯤은 저질러 보았을 이런 간단한 실수를 생각해 보세요:
#![allow(unused)] fn main() { fn as_str(data: &u32) -> &str { // String 만들기 let s = format!("{}", data); // OH NO! 이 함수 안에서만 존재하는 // 값의 레퍼런스를 반환했군요! // 달랑거리는 포인터 발생! 해제 후 사용! 이런! // (러스트에서는 컴파일되지 않습니다) &s } }
바로 이런 문제를 해결하기 위해서 러스트의 소유권 시스템이 만들어졌습니다. 러스트는 &s
가 살아있는 범위를 알고 있고, 이것으로 이 레퍼런스의 탈출을 막을 수 있습니다. 그러나 이 문제는 C 컴파일러라도 그럴듯하게 잡을 수 있는 간단한 경우입니다.
코드가 커지고 포인터들이 다양한 함수들에 인자로 넘겨지면 상황은 더 복잡해집니다. 결국 C 컴파일러는 과부하를 일으키게 되고, 당신의 코드가 불건전하다는 것을 증명하기에 충분한 포인터 분석을 하지 못하게 됩니다.
이렇게 되면 당신의 프로그램이 그냥 맞다고 생각하고 받아들여야만 하겠죠.
러스트에서는 이런 일이 절대 일어나지 않을 것입니다. 컴파일러에게 모든 것이 건전하다는 것을 증명하는 것은 프로그래머의 몫입니다.
물론, 러스트의 소유권 시스템은 그냥 레퍼런스들이 그 본체의 범위를 벗어나지 않는 것을 검증하는 것보다 훨씬 복잡합니다. 그것은 포인터들이 언제나 유효하도록 보장하는 것은 이것보다 훨씬 복잡하기 때문입니다. 예를 들어 이 코드에서,
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; // 내부 값의 레퍼런스를 받아옵니다 let x = &data[0]; // OH NO! `push`는 `data`의 포인터가 재할당되도록 유도합니다. // 달랑거리는 포인터! 해제 후 사용! 이런! // (러스트에서는 컴파일되지 않습니다) data.push(4); println!("{}", x); }
이 버그를 잡기 위해서는 단순한 범위 분석으로는 부족한데, 그것은 data
가 우리가 필요한 만큼 오래 살기 때문입니다. 그러나 우리가 레퍼런스를 가지고 있는 동안 바뀌었지요.
그래서 러스트는 레퍼런스가 본체와 다른 레퍼런스들을 수정하지 못하게 막는 것입니다.
레퍼런스
레퍼런스에는 두 가지 종류가 있습니다:
- 불변 레퍼런스:
&
- 가변 레퍼런스:
&mut
이것들은 다음의 규칙들을 지킵니다:
- 레퍼런스는 그 본체보다 오래 살 수 없다
- 가변 레퍼런스는 복제할 수 없다
이게 다입니다. 이것이 레퍼런스가 따르는 모델 전부입니다.
물론, 우리는 복제한다는 것이 어떤 의미인지 정의해야겠죠.
error[E0425]: cannot find value `aliased` in this scope
--> <rust.rs>:2:20
|
2 | println!("{}", aliased);
| ^^^^^^^ not found in this scope
error: aborting due to previous error
아쉽게도, 러스트는 아직 복제 모델을 정의하지 않았습니다. 🙀
러스트 개발자들이 언어의 의미를 확실히 정하는 동안, 다음 섹션에서 복제가 무엇인지, 왜 중요한지 논해 보겠습니다.
복제
먼저, 몇 가지 짚고 넘어가겠습니다:
-
논의를 위해 가능한 한 가장 넓은 복제의 정의를 사용할 것입니다. 러스트의 정의는 아마 변형이나 살아있음 여부에 대해서는 조금 더 제한적일 것입니다.
-
우리는 한 스레드에서, 인터럽트 없는 실행을 가정하겠습니다. 또한 메모리-매핑된 하드웨어 같은 것들은 무시하겠습니다. 러스트는 당신이 말하지 않는 한 이런 것들이 일어나지 않는다고 가정합니다. 더 자세한 내용은 동시성 챕터를 참고하세요.
이런 것들을 말했으니, 이제 우리의 아직 작업중인 정의를 말해보겠습니다: 변수들과 포인터들은 메모리의 겹쳐지는 지역을 가리킬 때 복제되었다고 합니다.
복제가 중요한 이유
그래서 왜 우리가 복제를 신경써야 할까요?
이런 간단한 함수를 생각해 보세요:
#![allow(unused)] fn main() { fn compute(input: &u32, output: &mut u32) { if *input > 10 { *output = 1; } if *input > 5 { *output *= 2; } // `input > 10`일 경우 `output`은 `2`가 될 것이라는 것을 기억하세요 } }
이 함수를 다음의 함수로 최적화할 수 있다면 좋겠네요:
#![allow(unused)] fn main() { fn compute(input: &u32, output: &mut u32) { let cached_input = *input; // `*input`을 레지스터에 저장합니다. if cached_input > 10 { // `input`이 10보다 크면, 이전 코드는 `output`을 1로 지정했다가 2를 곱하니, // `output`은 최종적으로 2가 됩니다 (`>10`이면 `>5`일 테니까요). // 여기서는 두번 할당하는 것을 피하고 한번에 2로 설정합니다. *output = 2; } else if cached_input > 5 { *output *= 2; } } }
러스트에서는 이런 최적화가 건전할 것입니다. 거의 모든 다른 언어에서는 그렇지 않을 것입니다 (전역 분석을 제외하면). 이것은 이 최적화가 복제가 일어나지 않는다는 것에 의존하기 때문인데, 많은 언어들이 이것에 있어서 자유롭게 풀어두죠.
특별히 우리는 input
과 output
이 겹치는 함수 매개변수들, 예를 들면 compute(&x, &mut x)
같은 것들을 걱정해야 합니다.
이런 입력으로는 이런 실행이 가능합니다:
// input == output == 0xabad1dea
// *input == *output == 20
if *input > 10 { // 참 (*input == 20)
*output = 1; // *input 에도 씀, *output 과 같기 때문
}
if *input > 5 { // 거짓 (*input == 1)
*output *= 2;
}
// *input == *output == 1
우리의 최적화된 함수는 이런 입력에 *output == 2
라는 결과를 도출할 것이고, 따라서 우리의 최적화가 올바른지의 문제는 이런 입력이 불가능하다는 것에 기반합니다.
우리는 러스트에서는 &mut
를 복제하는 것이 허락되지 않기 때문에, 이런 입력이 불가능하다는 것을 압니다. 따라서 우리는 안전하게 그런 가능성을 거부하고 최적화를 실행할 수 있게 됩니다.
다른 대부분의 언어들에서는 이런 입력 또한 완전히 가능할 것이고, 고려되어야 할 것입니다.
이것이 바로 복제 분석이 중요한 이유입니다: 유용한 최적화를 컴파일러가 실행하도록 해 주거든요! 몇 가지 예를 들자면:
- 값의 메모리를 접근하는 포인터가 없다는 것을 증명함으로써 값들을 레지스터에 그대로 두는 것
- 어떤 메모리는 마지막으로 읽은 후에 쓴 적이 없다는 것을 증명함으로써 읽기 작업들을 제거하는 것
- 어떤 메모리는 다음 쓰기 작업 전에 읽은 적이 없다는 것을 증명함으로써 쓰기 작업들을 제거하는 것
- 읽기 작업들이나 쓰기 작업들이 서로에 의존하지 않는다는 것을 증명함으로써 작업들을 옭기거나 순서를 바꾸는 것
이런 최적화는 또한 루프 벡터화, 상수 전파, 죽은 코드 제거 등의 더 큰 최적화의 건전함을 증명하게 되는 경향이 있습니다.
이전의 예제에서, 우리는 &mut u32
가 복제될 수 없다는 사실을 이용해서 *output
에 쓰는 작업이 *input
에 영향을 줄 수 없다는 것을 증명했습니다. 이러면 우리는 레지스터에 *input
을 캐싱해서, 읽기 작업을 제거할 수 있습니다.
이 읽기 작업을 캐싱함으로써, 우리는 > 10
분기에서 있는 쓰기 작업이 > 5
분기를 택하는지 여부를 영향주지 못한다는 것을 알게 되고, *input > 10
일 때에 읽고, 수정하고, 다시 쓰는 작업(*output
을 2배로 하는 작업)을 제거할 수 있게 됩니다.
복제 분석에 대해 꼭 기억해야 할 것은 쓰기 작업이 최적화를 방해하는 주된 걸림돌이라는 것입니다. 이 말은, 읽기 작업을 프로그램의 다른 곳으로 옮기는 것을 방해하는 유일한 것은 우리가 그것과 같은 메모리 위치에 쓰는 작업과 함께 순서를 바꾸는 것입니다.
예를 들어, 우리의 함수를 이렇게 수정한 버전이라면 우리는 복제에 대해서 걱정할 필요가 없는데, 왜냐하면 *output
에 쓰는 유일한 작업을 함수의 가장 끝으로 옮겼기 때문입니다. 이는 우리가 이 쓰기 작업 이전에 있는, *input
을 읽는 작업들을 자유롭게 재배치할 수 있다는 것을 의미합니다:
#![allow(unused)] fn main() { fn compute(input: &u32, output: &mut u32) { let mut temp = *output; if *input > 10 { temp = 1; } if *input > 5 { temp *= 2; } *output = temp; } }
우리는 아직도 input
이 temp
의 복제가 아니라는 것을 짐작하기 위해 복제 분석에 의존하지만, 증명은 훨씬 간단해집니다: 지역 변수의 값은 그것이 정의되기 전에 존재하던 것으로 복제할 수 없기 때문입니다.
이것은 모든 언어가 자유롭게 하는 짐작이고, 그래서 이 버전의 함수는 어느 언어에서든 우리가 원하는 대로 최적화시킬 수 있게 됩니다.
이것이 바로 러스트가 쓰는 "복제"의 정의가 살아있음과 변경 같은 개념이 동반되는 이유입니다: 실제로 메모리에 쓰는 작업이 없으면, 복제가 일어나도 상관없기 때문입니다.
물론, 러스트를 위한 총체적인 복제 모델은 함수 호출(보이지 않는 것들을 변경할 수도 있음)이나, 생 포인터 (그들 자체로는 복제의 요구사항이 없음),
그리고 UnsafeCell
(&
로 참조한 레퍼런스의 주체의 값이 변경되도록 허용함) 같은 것들도 고려해야만 합니다.
수명
러스트는 이런 규칙들을 수명을 통해서 강제합니다. 수명은 레퍼런스가 유효해야 하는, 이름이 지어진 코드의 지역입니다. 이 지역들은 꽤나 복잡할 수 있는데, 프로그램의 실행 분기에 대응하기 때문입니다. 또한 이런 실행 분기에는 구멍까지도 있을 수 있는데, 레퍼런스가 다시 쓰이기 전에 재초기화된다면, 이 레퍼런스를 무효화할 수 있기 때문입니다. 레퍼런스를 포함하는 (또는 포함하는 척하는) 타입도 수명을 붙여서 러스트가 그것을 무효화하는 것을 막게 할 수 있습니다.
우리의 예제 대부분, 수명은 코드 구역과 대응할 것입니다. 이것은 우리의 예제가 간단하기 때문입니다. 그렇게 대응되지 않는 복잡한 경우는 밑에 서술하겠습니다.
함수 본문 안에서 러스트는 보통 관련된 수명을 명시적으로 쓰게 하지 않습니다. 이것은 지역적인 문맥에서 수명에 대해 말하는 것은 대부분 별로 필요없기 때문입니다: 러스트는 모든 정보를 가지고 있고, 모든 것들이 가능한 한 최적으로 동작하도록 할 수 있습니다. 컴파일러가 아니라면 일일히 작성해야 하는 많은 무명 구역과 경계들을 컴파일러가 대신해 주어서 당신의 코드가 "그냥 작동하게" 해 줍니다.
그러나 일단 함수 경계를 건너고 나면 수명에 대해서 이야기해야만 합니다. 수명은 보통 작은 따옴표로 표시됩니다: 'a
, 'static
처럼요. 수명이라는 주제에 발가락을 담그기 위해서, 우리는 코드 구역을 수명으로 수식할 수 있는 척을 하며, 이 챕터의 시작부터 있는 예제들의 문법적 설탕을 해독해 보겠습니다.
원래 우리의 예제들은 코드 구역과 수명에 대해서 공격적인 문법적 설탕-- 고당 옥수수콘 시럽 같은 --을 이용했는데, 모든 것들을 명시적으로 적는 것은 굉장히 요란하기 때문입니다. 모든 러스트 코드는 이런 식의 공격적인 추론과 "뻔한" 것들을 생략하는 것에 의존합니다.
문법적 설탕 중 하나의 특별한 조각은 모든 let
문장이 암시적으로 코드 구역을 시작한다는 점입니다. 대부분의 경우에는 이것이 문제가 되지는 않습니다. 그러나 서로 참조하는 변수들에게는 문제가 됩니다.
간단한 예제로, 이런 간단한 러스트 코드 조각을 해독해 봅시다:
#![allow(unused)] fn main() { let x = 0; let y = &x; let z = &y; }
대여 검사기는 항상 수명의 길이를 최소화하려고 하기 때문에, 아마 이런 식으로 해독할 것입니다:
// 주의: `'a: {` 나 `&'b x` 는 유효한 문법이 아닙니다!
'a: {
let x: i32 = 0;
'b: {
let y: &'b i32 = &'b x;
'c: {
let z: &'c &'b i32 = &'c y; // "i32의 레퍼런스의 레퍼런스" (수명이 표시되어 있음)
}
}
}
와. 이건... 별로네요. 러스트가 이런 것들을 간단하게 만들어 준다는 것을 잠시 감사하는 시간을 가집시다.
...
자, 계속하자면, 외부 범위에 레퍼런스를 넘기면 러스트는 더 긴 수명을 추론하게 됩니다:
#![allow(unused)] fn main() { let x = 0; let z; let y = &x; z = y; }
'a: {
let x: i32 = 0;
'b: {
let z: &'b i32;
'c: {
// x의 레퍼런스가 'b 구역으로 넘겨지기 때문에
// 'b 가 됩니다.
let y: &'b i32 = &'b x;
z = y;
}
}
}
예제: 본체보다 오래 사는 레퍼런스들
좋습니다, 예전의 예제 중 몇 가지를 살펴봅시다:
#![allow(unused)] fn main() { fn as_str(data: &u32) -> &str { let s = format!("{}", data); &s } }
이렇게 해독됩니다:
fn as_str<'a>(data: &'a u32) -> &'a str {
'b: {
let s = format!("{}", data);
return &'a s;
}
}
as_str
의 시그니처는 어떤 수명을 가지고 있는, u32의 레퍼런스를 받고 딱 그만큼 사는, str의 레퍼런스를 만들 수 있다고 약속하고 있습니다. 이미 여기서 우리는 왜 이 시그니처가 문제가 있을 수도 있는지 알 수 있습니다. 이것은 우리가 str을 u32의 레퍼런스가 있던 코드 구역에서, 혹은 더 이전의 구역에서 찾을 것을 내포하고 있습니다. 이것은 좀 무리한 요구입니다.
그 다음 우리는 String s
를 계산하고, 그것을 가리키는 레퍼런스를 반환합니다. 우리의 함수가 체결한 계약은 레퍼런스가 'a
보다 더 살아야 한다고 되어 있으므로, 이것이 바로 우리가 이 레퍼런스에 할당해야 하는 수명입니다. 불행하게도 s
는 'b
구역에서 정의되었으므로, 이 코드가 건전할 수 있는 유일한 방법은 'b
가 'a
를 포함하는 것입니다 -- 'a
는 함수 호출 자체를 포함해야 하므로, 이는 명백하게 성립하지 않습니다. 그러므로 우리는 그 수명이 본체를 능가하는 레퍼런스를 만들었는데, 이것은 말 그대로 레퍼런스가 할 수 없다고 우리가 말했던 가장 첫번째 것입니다. 컴파일러는 당연히 우리 눈앞에서 터질 겁니다.
좀더 확실하게 하기 위해 이 예제를 확장해 볼 수 있습니다:
fn as_str<'a>(data: &'a u32) -> &'a str {
'b: {
let s = format!("{}", data);
return &'a s
}
}
fn main() {
'c: {
let x: u32 = 0;
'd: {
// x가 유효한 범위 전체 동안 빌림이 유지될 필요가
// 없기 때문에 새로운 구역을 집어넣었습니다. as_str의 반환값은
// 이 함수 호출 이전에 존재하는 str을 찾아야만 합니다.
// 당연히 일어나지 않을 일이죠.
println!("{}", as_str::<'d>(&'d x));
}
}
}
젠장!
이 함수를 올바르게 작성하는 방법은 물론 다음과 같습니다:
#![allow(unused)] fn main() { fn to_string(data: &u32) -> String { format!("{}", data) } }
우리는 함수 안에서 소유한 값을 생성해서 반환해야 합니다! 우리가 이렇게 &'a str
을 반환하려면 이것이 &'a u32
의 필드 안에 있어야만 하는데, 당연히 이 경우에는 말이 안됩니다.
(사실 우리는 문자열 상수값을 반환할 수도 있었습니다. 이 상수값은 스택의 맨 밑바닥에 있다고 생각할 수 있습니다. 이 구현이 우리가 원하는 것을 조금 제한하기는 하지만요.)
예제: 가변 레퍼런스의 복제
다른 예제를 볼까요:
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; let x = &data[0]; data.push(4); println!("{}", x); }
'a: {
let mut data: Vec<i32> = vec![1, 2, 3];
'b: {
// 'b 는 우리가 이 레퍼런스가 필요한 동안 유지됩니다
// (`println!`까지 가야 하죠)
let x: &'b i32 = Index::index::<'b>(&'b data, 0);
'c: {
// &mut가 더 오래 살아남을 필요가 없기 때문에
// 임시 구역을 추가합니다
Vec::push(&'c mut data, 4);
}
println!("{}", x);
}
}
여기서 문제는 조금 더 감추어져 있고 흥미롭습니다. 우리는 다음의 이유로 러스트가 이 프로그램을 거부하기를 원합니다: 우리는 data
의 하위 변수를 참조하는, 살아있는 불변 레퍼런스 x
를 가지고 있는데,
이 동안 data
에 push
함수를 호출하여 가변 레퍼런스를 취하려고 합니다. 이러면 복제된 가변 레퍼런스를 생성할 테고, 이것은 레퍼런스의 두번째 규칙을 위반할 것입니다.
하지만 이것은 러스트가 이 프로그램이 나쁘다고 알아내는 방법이 전혀 아닙니다. 러스트는 x
가 data
의 일부의 레퍼런스라는 것을 이해하지 못합니다. 러스트는 Vec
을 아예 이해하지 못합니다.
러스트가 보는 것은 x
가 출력되기 위해서는 'b
만큼 살아야 한다는 것입니다. Index::index
의 시그니처가 그 다음에 요구하는 것은 우리가 data
에서 만든 레퍼런스가 'b
만큼 살아야 한다는 것입니다.
우리가 push
를 호출하려고 할 때, 러스트는 우리가 &'c mut data
를 만드려고 한다는 것을 보게 됩니다. 러스트는 'c
가 'b
안에 있다는 것을 알게 되고, &'b data
가 아직 살아 있어야 하기 때문에 우리의 프로그램을 거부합니다!
여기서 우리는 우리가 보존하는 데에 실제로 관심이 있는 레퍼런스 의미론보다 수명 시스템이 훨씬 헐거운 것을 볼 수 있습니다. 대부분의 경우에는 이것은 아무 문제가 없습니다, 왜냐하면 이것은 우리가 우리의 프로그램을 컴파일러에게 하루종일 설명하는 것을 방지해 주기 때문입니다. 그러나 이것은 러스트의 진정한 의미론에 잘 부합하는 몇 가지의 프로그램들이, 수명이 너무 멍청하기 때문에, 거부되는 것을 의미하기는 합니다.
수명이 차지하는 영역
레퍼런스(종종 빌림이라고 불립니다)는 창조된 곳부터 마지막으로 쓰이는 곳까지 살아있습니다. 빌려진 값은 살아있는 빌림들보다만 오래 살면 됩니다. 이것은 간단하게 보이지만, 몇 가지 미묘한 것들이 있습니다.
다음의 코드 조각은 컴파일되는데, x
를 출력하고 난 후에는 더이상 필요하지 않아지므로, 이것이 달랑거리거나 복제되어도 상관없기 때문입니다 (변수 x
가 실제로는 이 구역 끝까지 존재한다고 해도 말이죠).
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; let x = &data[0]; println!("{}", x); // 이건 괜찮습니다, x는 더 이상 필요하지 않습니다 data.push(4); }
하지만, 만약 값이 소멸자가 있다면, 그 소멸자는 그 구역 끝에서 실행됩니다. 그리고 소멸자를 실행하는 것은 그 값을 사용하는 것으로 간주되죠 - 당연하게도 마지막으로요. 따라서, 이것은 컴파일되지 않을 것입니다.
#![allow(unused)] fn main() { #[derive(Debug)] struct X<'a>(&'a i32); impl Drop for X<'_> { fn drop(&mut self) {} } let mut data = vec![1, 2, 3]; let x = X(&data[0]); println!("{:?}", x); data.push(4); //여기서 소멸자가 실행되고 따라서 이 코드 조각은 컴파일에 실패합니다 }
컴파일러에게 x
가 더 이상 유효하지 않다고 설득하는 방법 중 하나는 data.push(4)
전에 drop(x)
를 사용하는 것입니다.
더 나아가서, 빌림의 가능한 마지막 사용처들이 여러 개 있을 수 있는데, 예를 들면 조건문의 각 가지입니다.
#![allow(unused)] fn main() { fn some_condition() -> bool { true } let mut data = vec![1, 2, 3]; let x = &data[0]; if some_condition() { println!("{}", x); // 이것이 이 가지에서 `x`의 마지막 사용입니다 data.push(4); // 따라서 여기서 push할 수 있죠 } else { // 여기에는 `x`의 사용이 없으므로, 사실상 마지막 사용은 // 이 예제 맨 위에서 x의 정의 시점입니다. data.push(5); } }
그리고 수명은 잠시 일시정지될 수 있습니다. 아니면 당신은 이것을 두 개의 별개의 빌림들이 같은 지역변수에 묶여 있다고 볼 수도 있습니다. 이것은 반복문 주변에서 종종 일어납니다 (반복문의 끝에서 변수에 새로운 값을 쓰고, 다음 차례 반복의 첫째 줄에서 그것을 마지막으로 사용하는 것이죠).
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; // 이 mut은 레퍼런스가 가리키는 곳을 바꿀 수 있게 해 줍니다 let mut x = &data[0]; println!("{}", x); // 이 빌림의 마지막 사용 data.push(4); x = &data[3]; // 여기서 새로운 빌림이 시작합니다 println!("{}", x); }
역사적으로 러스트는 빌림을 그 구역의 끝까지 살려 놓았으므로, 이런 예제들은 예전의 컴파일러에서는 컴파일에 실패할 수도 있습니다. 또한, 러스트가 빌림의 살아있는 범위를 제대로 줄이지 못하고, 컴파일되어야 할 것 같은 때에도 컴파일에 실패하는 희귀한 경우들이 있습니다. 이런 것들은 시간이 지나면서 해결될 것입니다.
수명의 한계
다음 코드가 주어졌을 때:
#[derive(Debug)] struct Foo; impl Foo { fn mutate_and_share(&mut self) -> &Self { &*self } fn share(&self) {} } fn main() { let mut foo = Foo; let loan = foo.mutate_and_share(); foo.share(); println!("{:?}", loan); }
이것이 컴파일이 되기를 기대할 수도 있습니다. 우리는 mutate_and_share
를 호출하는데, 이것은 foo
를 잠시 가변으로 빌리지만, 불변 레퍼런스만 반환하기 때문입니다. 따라서 foo
는 가변으로 빌린 상태가 아니므로 우리는 foo.share()
가 성공할 것이라고 기대할 것입니다.
하지만 우리가 이것을 컴파일하려고 하면:
error[E0502]: cannot borrow `foo` as immutable because it is also borrowed as mutable
--> src/main.rs:12:5
|
11 | let loan = foo.mutate_and_share();
| --- mutable borrow occurs here
12 | foo.share();
| ^^^ immutable borrow occurs here
13 | println!("{:?}", loan);
무슨 일이 일어난 걸까요? 음, 이것은 전 섹션의 2번째 예제와 정확히 동일한 논법입니다. 우리가 이 프로그램을 해독하면 다음을 얻습니다:
struct Foo;
impl Foo {
fn mutate_and_share<'a>(&'a mut self) -> &'a Self { &'a *self }
fn share<'a>(&'a self) {}
}
fn main() {
'b: {
let mut foo: Foo = Foo;
'c: {
let loan: &'c Foo = Foo::mutate_and_share::<'c>(&'c mut foo);
'd: {
Foo::share::<'d>(&'d foo);
}
println!("{:?}", loan);
}
}
}
수명 체계는 &mut foo
의 수명을 'c
로 늘려야 할 수밖에 없는데, 이는 loan
의 수명과 mutate_and_share
의 시그니처 때문입니다. 그 다음 우리가 share
를 호출하려 할 때, 수명 체계는 우리가 &'c mut foo
를 복제하려는 것을 알아차리고 우리 눈 앞에서 터집니다!
이 프로그램은 우리가 실제로 신경쓰는 레퍼런스 의미론에 따르면 명확히 옳지만, 수명 체계는 그것을 전달하기에 너무 헐겁습니다.
제대로 줄어들지 않은 빌림
다음의 코드는 컴파일되지 않는데, 이는 러스트가 map
이라는 변수가 두 번 빌려졌다고 보고, 첫번째 빌림이 두번째 빌림이 시작하기 전에 필요가 없어진다는 것을 추론할 수 없기 때문입니다. 이것은 첫번째 빌림이 구역 전체 동안 사용된다고 러스트가 보수적으로 판단했기 때문입니다. 이 문제는 나중에는 고쳐질 것입니다.
#![allow(unused)] fn main() { use std::collections::HashMap; use std::hash::Hash; fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V where K: Clone + Eq + Hash, V: Default, { match map.get_mut(&key) { Some(value) => value, None => { map.insert(key.clone(), V::default()); map.get_mut(&key).unwrap() } } } }
주어진 수명의 제한 때문에 &mut map
의 수명은 다른 가변 빌림과 겹치게 되고, 컴파일 에러로 이어집니다:
error[E0499]: cannot borrow `*map` as mutable more than once at a time
--> src/main.rs:12:13
|
4 | fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V
| -- lifetime `'m` defined here
...
9 | match map.get_mut(&key) {
| - --- first mutable borrow occurs here
| _____|
| |
10 | | Some(value) => value,
11 | | None => {
12 | | map.insert(key.clone(), V::default());
| | ^^^ second mutable borrow occurs here
13 | | map.get_mut(&key).unwrap()
14 | | }
15 | | }
| |_____- returning this value requires that `*map` is borrowed for `'m`
수명 생략
흔한 코드 패턴을 더 편하게 만들기 위해서, 러스트는 함수 시그니처에서 수명이 생략되는 것을 허용합니다.
수명의 위치는 타입 안에서 수명을 쓸 수 있는 곳 어디나입니다:
&'a T
&'a mut T
T<'a>
수명의 위치는 "입력" 또는 "출력"으로 나타날 수 있습니다:
-
fn
정의,fn
타입, 그리고Fn
,FnMut
,FnOnce
트레잇들에서, 입력은 매개변수들의 타입을 가리키고, 출력은 결과 타입을 가리킵니다. 따라서fn foo(s: &str) -> (&str, &str)
는 입력 위치에서 하나의 수명을, 출력 위치에서 두 개의 수명을 생략한 것이죠.fn
메서드 정의에서 입력 위치는 메서드의impl
부분에 있는 수명들(혹은, 기본 메서드에 한해서, 트레잇 헤더에 있는 수명들도요)을 포함하지 않는다는 것을 유의하세요. -
impl
부분에서는 모든 타입이 입력입니다. 따라서impl Trait<&T> for Struct<&T>
은 입력 위치에서 두 개의 수명을 생략했고,impl Struct<&T>
은 하나의 수명을 생략한 것입니다.
생략 규칙은 다음과 같습니다:
-
입력 위치에서 생략된 수명들은 각각 별개의 수명 매개변수가 됩니다.
-
입력 위치에서 수명이 하나밖에 없다면 (생략되든 되지 않든), 그 수명이 출력 수명들 모두에 적용됩니다.
-
만약 입력 위치에서 수명이 여러 개가 있는데, 그 중의 하나가
&self
이거나&mut self
라면,self
의 수명이 출력 위치에서 수명이 생략된 모든 수명들에게 할당됩니다. -
이 모든 경우가 해당하지 않는다면, 출력 수명을 생략하는 것은 오류입니다.
예제입니다:
fn print(s: &str); // 생략됨
fn print<'a>(s: &'a str); // 확장됨
fn debug(lvl: usize, s: &str); // 생략됨
fn debug<'a>(lvl: usize, s: &'a str); // 확장됨
fn substr(s: &str, until: usize) -> &str; // 생략됨
fn substr<'a>(s: &'a str, until: usize) -> &'a str; // 확장됨
fn get_str() -> &str; // 오류
fn frob(s: &str, t: &str) -> &str; // 오류
fn get_mut(&mut self) -> &mut T; // 생략됨
fn get_mut<'a>(&'a mut self) -> &'a mut T; // 확장됨
fn args<T: ToCStr>(&mut self, args: &[T]) -> &mut Command // 생략됨
fn args<'a, 'b, T: ToCStr>(&'a mut self, args: &'b [T]) -> &'a mut Command // 확장됨
fn new(buf: &mut [u8]) -> BufWriter; // 생략됨
fn new(buf: &mut [u8]) -> BufWriter<'_>; // 생략됨 (`rust_2018_idioms`을 사용해서)
fn new<'a>(buf: &'a mut [u8]) -> BufWriter<'a> // 확장됨
무제한 수명
불안전한 코드는 종종 허공에서 레퍼런스나 수명을 만들어내곤 합니다. 이런 수명들은 무제한의 상태로 세계에 들어오게 됩니다. 이것이 일어나는 가장 흔한 경로는 생 포인터를 역참조한 다음 그것의 레퍼런스를 취하는 것인데,
이것은 무제한의 수명을 가진 레퍼런스를 생산하게 됩니다. 이런 수명은 상황이 요구하는 만큼 범위가 커집니다. 이것은 사실 그냥 'static
이 되는 것보다 더 강력한데,
예를 들어 &'static &'a T
는 &'a &'a T
와 타입이 맞지 않을 것이지만, 무제한의 수명은 필요하다면 완벽하게 &'a &'a T
에 맞아들어갈 것이기 때문입니다. 그러나 대부분의 의도와 목적에서는,
이런 무제한의 수명은 'static
으로 간주해도 됩니다.
'static
인 레퍼런스는 거의 없으니, 이것은 아마 잘못된 것일 것입니다. transmute
와 transmute_copy
는 이런 일이 일어날 수 있는 또다른 두 가지의 원인들입니다.
우리는 최선을 다해서, 가능한 한 빨리 무제한의 수명을 제한시켜야 합니다, 특히 함수 경계를 지날 때 말이죠.
함수가 주어졌을 때 입력들에서 파생되지 않는 출력 수명은 무제한이 됩니다. 예를 들면:
fn get_str<'a>(s: *const String) -> &'a str { unsafe { &*s } } fn main() { let soon_dropped = String::from("hello"); let dangling = get_str(&soon_dropped); drop(soon_dropped); println!("잘못된 str: {}", dangling); // 잘못된 str: gӚ_` }
무제한의 수명을 피할 수 있는 가장 쉬운 방법은 함수 경계에서 수명을 생략하는 것입니다. 출력 수명이 생략되면 반드시 입력 수명에 제한되게 되니까요. 당연히 잘못된 수명에 제한될 수도 있겠지만, 이런 것들은 보통 메모리 안전이 흔하게 침범당하게 두기보다는, 컴파일 오류를 야기할 뿐입니다.
함수 안에서 수명을 제한하는 것은 더 오류가 일어나기 쉽습니다. 수명을 제한하는 가장 안전하고 가장 쉬운 방법은 제한된 수명이 있는 함수에서 반환하는 것입니다. 그러나 이것을 받아들일 수 없다면, 레퍼런스를 특정한 수명이 있는 위치에 놓을 수 있습니다. 불행히도 함수 안에서 연관된 모든 수명들을 다 나열할 수는 없습니다.
상계 트레잇 제한 (Higher-Rank Trait Bounds, HRTBs)
러스트의 Fn
트레잇은 조금 마법 같습니다. 예를 들어, 다음의 코드를 쓸 수 있겠습니다:
struct Closure<F> { data: (u8, u16), func: F, } impl<F> Closure<F> where F: Fn(&(u8, u16)) -> &u8, { fn call(&self) -> &u8 { (self.func)(&self.data) } } fn do_it(data: &(u8, u16)) -> &u8 { &data.0 } fn main() { let clo = Closure { data: (0, 1), func: do_it }; println!("{}", clo.call()); }
만약 우리가 순진하게 수명 섹션에서 했던 대로 이 코드를 해독하려 하면, 좀 문제가 발생합니다:
// 주의: `&'b data.0`와 `'x: {`은 올바른 문법이 아닙니다!
struct Closure<F> {
data: (u8, u16),
func: F,
}
impl<F> Closure<F>
// where F: Fn(&'??? (u8, u16)) -> &'??? u8,
{
fn call<'a>(&'a self) -> &'a u8 {
(self.func)(&self.data)
}
}
fn do_it<'b>(data: &'b (u8, u16)) -> &'b u8 { &'b data.0 }
fn main() {
'x: {
let clo = Closure { data: (0, 1), func: do_it };
println!("{}", clo.call());
}
}
세상에, 어떻게 우리가 F
의 트레잇 제한에서 수명을 이야기할 수 있을까요? 이곳에 어떤 수명을 제공해야 하지만, 우리가 신경쓰는 수명은 우리가 call
의 본문에 들어가기 전에는 이름을 붙일 수가 없습니다! 또한, 이 수명은 어떤 정해진 수명이 아닙니다; call
은 그 시점에 &self
가 가지고 있게 되는 아무 수명과 작업이 가능합니다.
이 작업은 상계 트레잇 제한(Higher-Rank Trait Bounds, HRTBs)의 마법이 필요합니다. 우리가 일전의 코드를 다시 해독하면 이와 같습니다:
where for<'a> F: Fn(&'a (u8, u16)) -> &'a u8,
또는 이렇게요:
where F: for<'a> Fn(&'a (u8, u16)) -> &'a u8,
(여기서 Fn(a, b, c) -> d
자체는 불안정한 실제 Fn
트레잇을 위한 문법 설탕입니다)
for<'a>
는 "모든 'a
의 선택들에 대해서"로 읽을 수 있고, 기본적으로 F가 만족시켜야 하는 트레잇 제한들의 무한한 목록을 만들어 냅니다. 살벌하군요. Fn
트레잇들 밖에서 상계 트레잇 제한들(HRTBs)을 만날 수 있는 곳은 별로 없고, 그런 경우들에도 우리는 대부분의 경우에 멋진 마법 문법 설탕이 있습니다.
요약하자면, 우리는 원래 코드를 좀더 명확하게, 이렇게 쓸 수 있습니다:
struct Closure<F> { data: (u8, u16), func: F, } impl<F> Closure<F> where for<'a> F: Fn(&'a (u8, u16)) -> &'a u8, { fn call(&self) -> &u8 { (self.func)(&self.data) } } fn do_it(data: &(u8, u16)) -> &u8 { &data.0 } fn main() { let clo = Closure { data: (0, 1), func: do_it }; println!("{}", clo.call()); }
부분타입 다형성과 변성(變性, Variance)
러스트는 빌림과 소유권 사이의 관계를 추적하기 위해 수명을 사용합니다. 하지만 수명의 순진한 구현은 너무 제한적이거나, 아니면 미정의 동작을 허용하게 됩니다.
수명을 유연하게 사용하면서도 수명의 오용을 방지하기 위해서, 러스트는 부분타입 다형성 과 변성(變性, Variance) 을 사용합니다.
예제와 함께 시작해 보죠.
// 주의: debug는 수명이 *같은* 두 개의 매개변수를 기대합니다. fn debug<'a>(a: &'a str, b: &'a str) { println!("a = {a:?} b = {b:?}"); } fn main() { let hello: &'static str = "hello"; { let world = String::from("world"); let world = &world; // 'world 는 'static 보다 짧은 수명입니다 debug(hello, world); } }
보수적인 수명의 구현에서는 hello
와 world
는 다른 수명을 가지고 있으므로, 우리는 다음과 같은 오류를 볼지도 모릅니다:
error[E0308]: mismatched types
--> src/main.rs:10:16
|
10 | debug(hello, world);
| ^
| |
| expected `&'static str`, found struct `&'world str`
이것은 뭔가 부적절할 것입니다. 이 경우에 우리가 원하는 것은 최소한 'world
만큼만 사는 타입은 모두 받는 것입니다. 우리의 수명들에 부분타입 다형성을 이용해 봅시다.
부분타입 다형성
부분타입 다형성은 한 타입이 다른 타입 대신에 쓰일 수 있다는 개념입니다.
Sub
이라는 타입이 Super
라는 타입의 부분타입이라고 해 봅시다 (우리는 이 단원에서 이것을 Sub <: Super
라고 표현하는 표기법을 사용하겠습니다).
이것이 우리에게 나타내는 것은 Super
가 정의하는 요구사항들의 집합을 Sub
이 완벽하게 충족한다는 것입니다. 그 다음 Sub
은 더 많은 요구사항을 가질 수 있겠죠.
이제, 부분타입 다형성을 수명에 쓰기 위해, 우리는 수명의 요구사항을 정의해야 합니다:
'a
는 코드 구역을 정의한다.
이제 수명을 위한 요구사항을 만들었으니, 우리는 수명들이 서로 어떻게 관련이 있는지를 정의할 수 있습니다:
'long
이 정의하는 코드 구역이'short
가 정의하는 구역을 완전히 포함할 때, 그리고 오직 그 경우에만'long <: 'short
이다.
'long
은 'short
가 정의한 구역보다 더 넓은 코드 구역을 정의할 수 있지만, 그래도 우리의 정의에 어긋나지 않습니다.
우리가 이 단원의 나머지를 통해서 보겠지만, 부분타입 다형성은 이것보다는 훨씬 복잡하고 세밀하지만, 이 간단한 규칙은 직관상 99%로 아주 좋습니다. 그리고 만약 불안전한 코드를 작성하지 않는다면, 컴파일러가 당신을 위해 온갖 특수한 경우를 다 처리해 줄 겁니다. 하지만 이것은 러스토노미콘이죠. 우리는 불안전한 코드를 작성할 것이니, 우리는 이것이 실제로 어떻게 동작하는지, 그리고 우리가 이것을 어떻게 가지고 놀 수 있을지를 이해해야 합니다.
위의 예제로 돌아오면, 우리는 'static <: 'world
라고 말할 수 있습니다. 지금으로써는, 수명의 부분타입 관계가 레퍼런스에도 그대로 전달된다는 것을 일단은 받아들입시다 (더 자세한 건 변성에서 다룹니다).
예를 들어, &'static str
은 &'world str
의 부분타입이므로, 우리는 &'static str
을 &'world str
로 "격하시킬" 수 있습니다. 이렇게 하면, 위의 예제는 컴파일될 겁니다:
fn debug<'a>(a: &'a str, b: &'a str) { println!("a = {a:?} b = {b:?}"); } fn main() { let hello: &'static str = "hello"; { let world = String::from("world"); let world = &world; // 'world 는 'static 보다 짧은 수명입니다. debug(hello, world); // hello 는 조용히 `&'static str`을 `&'world str`로 격하시킵니다. } }
변성(變性, Variance)
위에서 우리는 'static <: 'b
가 &'static T <: &'b T
를 함의한다는 것을 대충 넘어갔었습니다. 이것은 변성이라고 알려진 속성을 사용한 것인데요. 이 예제처럼 간단하지만은 않습니다. 이것을 이해하기 위해,
이 예제를 조금 확장해 보죠:
fn assign<T>(input: &mut T, val: T) { *input = val; } fn main() { let mut hello: &'static str = "hello"; { let world = String::from("world"); assign(&mut hello, &world); } println!("{hello}"); // 해제 후 사용 😿 }
assign
에서 우리는 hello
레퍼런스를 world
를 향해 가리키도록 합니다. 하지만 그 다음 world
는, 나중에 hello
가 println!
에서 사용되기 전에, 구역 밖으로 벗어나고 맙니다.
이것은 전형적인 "해제 후 사용" 버그입니다!
우리의 본능은 먼저 assign
의 구현을 나무랄 수도 있겠지만, 여기에는 잘못된 것이 없습니다. 우리가 T
타입의 값을 T
타입에 할당하는 것이 그렇게 무리는 아닐 겁니다.
문제는 우리가 &mut &'static str
과 &mut &'b str
이 서로 호환되는지를 짐작할 수 없다는 점입니다. 이것이 의미하는 것은 &mut &'static str
이 &mut &'b str
의 부분타입이 될 수 없다는 말입니다,
비록 'static
이 'b
의 부분타입이라고 해도요.
변성은 제네릭 매개변수를 통한 부분타입들간의 관계를 정의하기 위해 러스트가 빌린 개념입니다.
주의: 편의를 위해 우리는 제네릭 타입을
F<T>
로 정의하여T
에 대해 쉽게 말할 것입니다. 이것이 문맥에서 잘 드러나길 바랍니다.
타입 F
의 변성은 그 입력들의 부분타입 다형성이 출력들의 부분타입 다형성에 어떻게 영향을 주느냐 하는 것입니다. 러스트에서는 세 가지 종류의 변성이 있습니다. 두 타입 Sub
과 Super
가 있고, Sub
이 Super
의 부분타입일 때:
F<Sub>
이F<Super>
의 부분타입일 경우F
는 공변(共變)합니다 (부분타입 특성이 전달됩니다)F<Super>
가F<Sub>
의 부분타입일 경우F
는 반변(反變)합니다 (부분타입 특성이 "뒤집힙니다")- 그 외에는
F
는 무변(無變)합니다 (부분타입 관계가 존재하지 않습니다)
우리가 위의 예제에서 기억한다면, 'a <: 'b
일 경우 &'a T
를 &'b T
의 부분타입으로 다뤄도 되었으니, &'a T
는 'a
에 대해서 공변하는 것이군요.
또한, 우리는 &mut &'a U
를 &mut &'b U
의 부분타입으로 다루면 안된다는 것을 보았으니, &mut T
는 T
에 대해서 무변하다고 말할 수 있겠습니다.
여기 다른 제네릭 타입들과 그들의 변성에 대한 표입니다:
'a | T | U | |
---|---|---|---|
&'a T | 공변 | 공변 | |
&'a mut T | 공변 | 무변 | |
Box<T> | 공변 | ||
Vec<T> | 공변 | ||
UnsafeCell<T> | 무변 | ||
Cell<T> | 무변 | ||
fn(T) -> U | 반변 | 공변 | |
*const T | 공변 | ||
*mut T | 무변 |
이 중의 몇 가지는 다른 것들과의 관계로 설명할 수 있습니다:
Vec<T>
와 다른 모든 소유하는 포인터들과 컬렉션들은Box<T>
와 같은 논리를 따릅니다Cell<T>
와 다른 모든 내부 가변성이 있는 타입들은UnsafeCell<T>
와 같은 논리를 따릅니다UnsafeCell<T>
는 내부 가변성이 있으므로&mut T
와 같은 변성을 가지게 됩니다*const T
는&T
와 같은 논리를 따릅니다*mut T
는&mut T
(또는UnsafeCell<T>
)와 같은 논리를 따릅니다
더 많은 타입에 대해서는 참조서의 "Variance" 섹션을 보세요.
주의: 러스트 언어에서 반변 타입의 유일한 예는 함수의 매개변수이고, 따라서 실제 상황에서는 크게 와닿지 않습니다. 반변성을 끌어내려면 특정 수명을 가지고 있는 레퍼런스를 매개변수로 받는 함수 포인터를 가지고 고차원적인 프로그래밍을 해야 합니다 (만약 "아무 수명"을 모두 받는 레퍼런스였다면, 상계 수명을 이용하게 되는데, 이것은 부분타입 다형성과 독립적으로 작동하기 때문입니다).
이제 우리가 변성에 대한 좀 더 정식적인 이해를 했으니, 더 많은 예제를 더 자세히 살펴봅시다.
fn assign<T>(input: &mut T, val: T) { *input = val; } fn main() { let mut hello: &'static str = "hello"; { let world = String::from("world"); assign(&mut hello, &world); } println!("{hello}"); }
이것을 실행하면 어떤 결과가 나오나요?
error[E0597]: `world` does not live long enough
--> src/main.rs:9:28
|
6 | let mut hello: &'static str = "hello";
| ------------ type annotation requires that `world` is borrowed for `'static`
...
9 | assign(&mut hello, &world);
| ^^^^^^ borrowed value does not live long enough
10 | }
| - `world` dropped here while still borrowed
다행이군요, 컴파일되지 않습니다! 여기서 무슨 일이 일어나고 있는 건지 자세하게 쪼개봅시다.
먼저 assign
함수를 봅시다:
#![allow(unused)] fn main() { fn assign<T>(input: &mut T, val: T) { *input = val; } }
이것이 하는 일은 가변 레퍼런스와 값을 받아서 가변 레퍼런스의 원본을 그 값으로 바꿔치기하는 것밖에 없습니다. 이 함수에 대해 중요한 것은 이 함수가 타입 동치 제약을 만든다는 점입니다. 이 함수는 시그니처에서 레퍼런스의 원본과 값은 아주 똑같은 타입이어야 한다고 명시하고 있습니다.
한편 우리는 이 함수에 &mut &'static str
과 &'world str
을 전달합니다.
&mut T
가 T
에 대해서 무변하기 때문에, 컴파일러는 첫째 매개변수에 아무런 부분타입 관계도 적용할 수 없다고 결론짓고, 따라서 T
는 정확히 &'static str
이어야만 하게 됩니다.
이것은 &T
의 경우와 반대입니다:
#![allow(unused)] fn main() { fn debug<T: std::fmt::Debug>(a: T, b: T) { println!("a = {a:?} b = {b:?}"); } }
여기도 비슷하게 a
와 b
는 같은 타입 T
를 가져야만 하는군요. 하지만 &'a T
가 'a
에 대해서 공변하기 때문에, 우리는 부분타입 변환을 할 수 있습니다.
따라서 컴파일러는 &'static str
이 &'b str
의 부분타입인 경우에, 그리고 오직 그 경우에만, &'static str
은 &'b str
이 될 수 있다고 결정합니다.
이것은 'static <: 'b
이면 성립할 텐데, 이 조건은 참이므로, 컴파일러는 행복하게 이 코드의 컴파일을 계속하게 됩니다.
보시다 보면 알겠지만, 왜 Box
(와 Vec
, HashMap
, 등등)가 공변해도 괜찮은지는 수명이 왜 공변해도 괜찮은지와 비슷합니다:
당신이 이것들에 가변 레퍼런스 같은 것을 끼워넣으려고 한다면, 그들은 무변성을 상속받고 당신은 안 좋은 짓을 하는 것에서 방지될 테니까요.
한편
Box
는 우리가 그냥 지나쳤던, 레퍼런스의 값의 측면에 집중하기 쉽게 해 줍니다.값의 레퍼런스들이 얼마든지 복제되어서 자유롭게 읽고 쓸 수 있게 하는 많은 언어들과 달리, 러스트는 매우 엄격한 규칙이 있습니다: 당신이 값을 변경하거나 이동할 수 있다면, 당신이 접근 권한을 가진 유일한 사람이라는 뜻입니다.
다음 코드를 생각해 봅시다:
let hello: Box<&'static str> = Box::new("hello"); let mut world: Box<&'b str>; world = hello;
우리가
hello
가'static
동안 살아 있었다는 것을 잊은 것은 아무런 문제가 되지 않습니다, 왜냐면 우리가hello
를'b
동안만 살아 있다고 알고 있는 변수에 옮겼을 때, 우리는 그것이 더 오래 살았다고 우주에서 유일하게 알고 있던 것을 없앴기 때문입니다!
이제 설명할 것이 하나만 남았군요: 함수 포인터입니다.
fn(T) -> U
가 왜 U
에 대해서 공변해야 하는지 보기 위해, 다음의 시그니처를 생각해 봅시다:
fn get_str() -> &'a str;
이 함수는 어떤 수명 'a
에 묶인 str
을 생산한다고 주장합니다. 그런 의미에서, 대신 이런 시그니처의 함수를 제공해도 완벽히 유효합니다:
fn get_static() -> &'static str;
이 함수를 호출할 때, 반환되길 기대하는 값은 최소한 'a
만큼 사는 &str
이니, 실제로는 값이 더 살아도 상관 없겠죠.
그러나, 매개변수들에는 같은 논리가 통하지 않습니다. 이 조건을:
fn store_ref(&'a str);
이것으로 만족시켜 보려고 생각해 보세요:
fn store_static(&'static str);
첫번째 함수는 최소한 'a
만큼만 산다면 아무 문자열 레퍼런스를 받을 수 있지만, 두번째는 'static
보다 짧게 사는 문자열 레퍼런스를 받을 수 없으니, 이것은 갈등을 초래하겠군요. 여기서는 공변성이 통하지 않습니다.
하지만 이것을 반대로 생각하면, 말이 됩니다! 만약 우리가 &'static str
을 받는 함수가 필요하다면, 아무 레퍼런스 수명이나 받는 함수는 잘 동작할 겁니다.
실전에서 한번 살펴보죠.
use std::cell::RefCell; thread_local! { pub static StaticVecs: RefCell<Vec<&'static str>> = RefCell::new(Vec::new()); } /// 주어진 input을 스레드 지역변수 `Vec<&'static str>`에 집어넣습니다 fn store(input: &'static str) { StaticVecs.with_borrow_mut(|v| v.push(input)); } /// 함수와 입력값을 받아서 입력값을 함수에 호출합니다 (같은 수명이어야 합니다!) fn demo<'a>(input: &'a str, f: fn(&'a str)) { f(input); } fn main() { demo("hello", store); // "hello"는 'static입니다. `store`를 문제없이 호출할 수 있죠. { let smuggle = String::from("smuggle"); // `&smuggle`은 'static이 아닙니다. 만약 우리가 `store`에 `&smuggle`을 전달하면, // `StaticVecs`에 잘못된 수명을 집어넣어 버린 게 될 겁니다. // 따라서, `fn(&'static str)`은 `fn(&'a str)`의 부분타입이 될 수 없습니다. demo(&smuggle, store); } // 해제 후 사용 😿 StaticVecs.with_borrow(|v| println!("{v:?}")); }
그리고 이것이 다른 타입들과 달리, 함수 타입들이 그 매개변수들에 대해서 반변하는 이유입니다.
자, 이제 표준 라이브러리가 제공하는 타입들은 잘 살펴보았는데, 당신이 정의한 타입들의 변성은 어떨까요? 간단하게 말하자면, 구조체는 그 필드들의 변성을 상속받습니다.
만약 MyType
구조체가 필드 a
에 쓰이는 제네릭 매개변수 A
를 가지고 있다면, A
에 대한 MyType
의 변성은 A
에 대한 a
의 변성과 똑같습니다.
하지만 만약 A
가 여러 필드에 쓰인다면:
A
를 사용하는 모든 타입이 공변한다면,MyType
은A
에 대해서 공변합니다A
를 사용하는 모든 타입이 반변한다면,MyType
은A
에 대해서 반변합니다- 그 외에는,
MyType
은A
에 대해서 무변합니다
#![allow(unused)] fn main() { use std::cell::Cell; struct MyType<'a, 'b, A: 'a, B: 'b, C, D, E, F, G, H, In, Out, Mixed> { a: &'a A, // 'a와 A에 대해서 공변합니다 b: &'b mut B, // 'b에 대해서 공변하고 B에 대해서 무변합니다 c: *const C, // C에 대해서 공변합니다 d: *mut D, // D에 대해서 무변합니다 e: E, // E에 대해서 공변합니다 f: Vec<F>, // F에 대해서 공변합니다 g: Cell<G>, // G에 대해서 무변합니다 h1: H, // 원래대로라면 H에 대해서 공변하겠지만... h2: Cell<H>, // 변성이 충돌하면 무변성이 이기기 때문에, H에 대해서 무변하게 됩니다 i: fn(In) -> Out, // In에 대해서 반변하고, Out에 대해서 공변합니다 k1: fn(Mixed) -> usize, // 원래대로라면 Mixed에 대해서 반변하겠지만.. k2: Mixed, // 변성이 충돌할 경우 무변성이 되기 때문에, Mixed에 대해서 무변하게 됩니다 } }
해제 검사
우리는 수명이 간단한 규칙으로 우리가 달랑거리는 레퍼런스를 절대 읽지 않도록 보장하는 것을 봤습니다. 하지만 이 동안 우리는 한 수명이 다른 수명보다 오래 산다고 할 때, 두 수명이 같은 경우도 포함한 경우만 말했습니다. 즉, 우리가 'a: 'b
라고 할 때, 'a
가 딱 'b
만큼만 살아도 괜찮았다는 말입니다. 처음 보면, 이것은 의미없는 구분 같습니다. 같은 시점에 동시에 해제되는 값은 없잖아요, 그렇죠? 그래서 우리는 이런 let
문장들을:
let x;
let y;
이렇게 해독할 수 있었죠:
{
let x;
{
let y;
}
}
이렇게 코드 구역을 이용해서 해독할 수 없는, 좀더 복잡한 상황들도 있지만, 순서는 여전히 정의되어 있습니다 - 변수들은 그 정의 순서의 반대로 해제되고, 구조체와 튜플의 필드들은 그 정의 순서대로 해제됩니다. RFC 1857에 해제 순서에 대한 좀더 자세한 내용이 있습니다.
이렇게 해 봅시다:
let tuple = (vec![], vec![]);
왼쪽 벡터가 먼저 해제됩니다. 하지만 이것이 대여 검사기의 눈에 오른쪽 벡터가 왼쪽 벡터보다 엄밀하게 더 오래 산다는 것을 뜻할까요? 이 질문의 답은 아니라는 겁니다. 대여 검사기는 튜플의 필드들을 분리해서 추적할 수 있지만, 벡터 원소들에 대해서는 무엇이 무엇보다 오래 사는지 결정할 수 없을 겁니다, 왜냐하면 그 원소들은 대여 검사기가 이해하지 못하는, 순수한 라이브러리 코드로 해제되기 때문입니다.
그래서 우리는 이것을 왜 신경쓸까요? 우리가 신경쓰는 이유는 만약 타입 시스템이 주의하지 않으면, 우발적으로 달랑거리는 포인터를 만들 수도 있기 때문입니다. 다음의 간단한 프로그램을 생각해 보세요:
struct Inspector<'a>(&'a u8); struct World<'a> { inspector: Option<Inspector<'a>>, days: Box<u8>, } fn main() { let mut world = World { inspector: None, days: Box::new(1), }; world.inspector = Some(Inspector(&world.days)); }
이 프로그램은 완벽히 건전하고 오늘날 컴파일됩니다. days
가 inspector
보다 엄밀하게 더 오래 살지는 않는다는 사실이 중요하지 않습니다. inspector
가 살아있는 동안, days
도 그럴 테니까요.
그러나 우리가 소멸자를 추가하면, 이 프로그램은 더 이상 컴파일되지 않습니다!
struct Inspector<'a>(&'a u8); impl<'a> Drop for Inspector<'a> { fn drop(&mut self) { println!("I was only {} days from retirement!", self.0); } } struct World<'a> { inspector: Option<Inspector<'a>>, days: Box<u8>, } fn main() { let mut world = World { inspector: None, days: Box::new(1), }; world.inspector = Some(Inspector(&world.days)); // `days`가 먼저 해제되게 되었다고 가정해 봅시다. // 그럼 `Inspector`가 해제될 때, 이미 해제된 메모리를 읽으려고 할 겁니다! }
error[E0597]: `world.days` does not live long enough
--> src/main.rs:19:38
|
19 | world.inspector = Some(Inspector(&world.days));
| ^^^^^^^^^^^ borrowed value does not live long enough
...
22 | }
| -
| |
| `world.days` dropped here while still borrowed
| borrow might be used here, when `world` is dropped and runs the destructor for type `World<'_>`
필드의 순서를 바꾸거나 구조체 대신 튜플을 쓴다 해도, 컴파일되지는 않을 겁니다.
Drop
을 구현하는 것은 Inspector
가 죽는 동안 어떤 임의의 코드를 실행하게 해 줍니다. 이것이 의미하는 것은 Inspector
가 사는 동안 살기로 되어있는 타입들이 실제로는 먼저 해제되었는지 관찰할 수도 있다는 것입니다.
흥미롭게도, 제네릭 타입들만 이런 것에 대해서 걱정해야 합니다. 제네릭이 아니라면, 그들이 사용할 수 있는 수명은 'static
밖에 없는데, 이것은 정말로 영원히 살 것이기 때문입니다. 이것이 바로 이런 문제가 건전한 제네릭 해제라고 불리는 이유입니다. 건전한 제네릭 해제는 해제 검사기에 의해 강제됩니다. 이 글을 쓰는 시점에서, 해제 검사기(dropck라고도 부릅니다)가 어떻게 타입을 검증하는지에 대한 자세한 내용은 전혀 정해져 있지 않습니다. 하지만 큰 틀에서의 규칙은 우리가 이 섹션 내내 집중해온 엄밀함입니다:
제네릭 타입이 건전하게 Drop
을 구현하려면, 그 제네릭 매개변수들이 엄밀하게 더 오래 살아야 합니다.
이 규칙을 지키는 것은 (보통은) 대여 검사기를 만족시키기 위해서 필수적입니다; 이 규칙을 지키는 것은 건전하기에는 충분하지만 필수적이지는 않습니다. 즉, 당신의 타입이 이 규칙을 지킨다면 해제되기에 확실히 건전하다는 말입니다.
이 규칙을 만족시키는 것이 항상 필수는 아닌 이유는 타입이 빌린 데이터를 접근할 수 있음에도, 어떤 Drop
구현은 빌린 데이터를 접근하지 않거나,
아니면 우리가 세부적인 해제 순서를 알고, 그럼에도 빌린 데이터는 괜찮을 것을 알 수도 있기 때문입니다, 비록 대여 검사기가 이를 모르더라도요.
예를 들어, 위의 Inspector
를 이렇게 변형하면 빌린 데이터를 절대 접근하지 않을 겁니다:
struct Inspector<'a>(&'a u8, &'static str); impl<'a> Drop for Inspector<'a> { fn drop(&mut self) { println!("Inspector(_, {})는 보지 *않아야* 할 때를 압니다.", self.1); } } struct World<'a> { inspector: Option<Inspector<'a>>, days: Box<u8>, } fn main() { let mut world = World { inspector: None, days: Box::new(1), }; world.inspector = Some(Inspector(&world.days, "gadget")); // `days`가 먼저 해제되게 된다고 해 봅시다. // `Inspector`가 해제되어도, 그 소멸자는 빌린 `days`를 // 접근하지 않을 겁니다. }
마찬가지로, 이렇게 변형한 것도 빌린 데이터를 절대 접근하지 않을 겁니다:
struct Inspector<T>(T, &'static str); impl<T> Drop for Inspector<T> { fn drop(&mut self) { println!("Inspector(_, {})는 보지 *않아야* 할 때를 압니다.", self.1); } } struct World<T> { inspector: Option<Inspector<T>>, days: Box<u8>, } fn main() { let mut world = World { inspector: None, days: Box::new(1), }; world.inspector = Some(Inspector(&world.days, "gadget")); // `days`가 먼저 해제되게 된다고 해 봅시다. // `Inspector`가 해제되어도, 그 소멸자는 빌린 `days`를 // 접근하지 않을 겁니다. }
하지만, 위의 수정된 코드들은 fn main
을 분석할 때 둘 모두 대여 검사기에게 거부됩니다, "days
가 충분히 오래 살지 않는다"고 말이죠.
그 이유는 main
의 대여 검사는 Inspector
의 Drop
구현의 내부는 모르기 때문입니다. 대여 검사기가 main
을 분석하는 동안 아는 것이라고는, Inspector
의 소멸자의 본문이 빌린 데이터를 접근할 수도 있다는 점입니다.
따라서, 해제 검사기는 어떤 값 안에 있는 모든 빌린 데이터(레퍼런스가 아닌 본체)가 그 값보다 오래 살도록 강제합니다.
탈출 장치
해제 검사를 좌우하는 정확한 규칙들은 미래에는 좀 덜 엄격할 수도 있겠습니다.
현재의 분석은 의도적으로 보수적이고 흔합니다; 어떤 값에 있는 모든 레퍼런스의 본체들이 그 값보다 오래 살도록 강제하는데, 이것은 확실히 건전하기 때문입니다.
언어의 미래 버전들은 분석을 더 예리하게 해서, 건전한 코드가 안전하지 않다고 거부되는 경우들을 줄일 수 있을 것입니다. 그러면 위의 두 Inspector
들과 같이 소멸하는 동안 그 원소를 접근하지 않는 경우를 해결하는 데 도움이 될 것입니다.
그 동안, 제네릭 타입의 소멸자가 수명이 다한 데이터를 접근하지 않는다고 (불안전하게) 보장하는 불안정 속성이 있습니다, 비록 그 타입으로는 수명이 다한 데이터를 접근할 수 있더라도요.
이 속성은 may_dangle
이라 부르고, RFC 1327에서 소개되었습니다. 이것을 위의 Inspector
에 적용하려면, 이렇게 씁니다:
#![feature(dropck_eyepatch)] struct Inspector<'a>(&'a u8, &'static str); unsafe impl<#[may_dangle] 'a> Drop for Inspector<'a> { fn drop(&mut self) { println!("Inspector(_, {})는 보지 *않아야* 할 때를 압니다.", self.1); } } struct World<'a> { days: Box<u8>, inspector: Option<Inspector<'a>>, } fn main() { let mut world = World { inspector: None, days: Box::new(1), }; world.inspector = Some(Inspector(&world.days, "gadget")); }
이 속성을 사용하려면 Drop
구현을 unsafe
로 수식해야 하는데, 이는 수명이 다했을 수 있는 데이터(예를 들어 위의 self.0
)를 접근하지 않는다는 보장을 컴파일러가 검사하지 않기 때문입니다.
이 속성은 어느 숫자의 수명이나 타입 매개변수에도 적용할 수 있습니다. 다음의 예제에서 우리는 'b
의 수명을 갖는 레퍼런스를 접근하지 않고, T
는 오직 이동 혹은 해제만 할 것이라고 컴파일러에게 알립니다. 하지만 'a
와 U
에 대해서는 이 속성을 쓰지 않음으로써 우리가 이 수명과 이 타입의 데이터를 접근할 것이라고 알립니다:
#![allow(unused)] #![feature(dropck_eyepatch)] fn main() { use std::fmt::Display; struct Inspector<'a, 'b, T, U: Display>(&'a u8, &'b u8, T, U); unsafe impl<'a, #[may_dangle] 'b, #[may_dangle] T, U: Display> Drop for Inspector<'a, 'b, T, U> { fn drop(&mut self) { println!("Inspector({}, _, _, {})", self.0, self.3); } } }
보통은 위의 경우처럼, 필드 접근이 불가능하다는 것이 자명할 때가 많습니다. 그러나 제네릭 타입 매개변수를 가지고 작업하다 보면, 그런 접근이 간접적으로 일어날 수 있습니다. 간접적인 접근의 예는 다음과 같습니다:
- 콜백 호출하기
- 트레잇 메서드를 통해서
(impl
구체화 같은 러스트의 미래 변화에 따라, 이런 간접적 접근을 가능하게 하는 방법이 더 많아질 수 있습니다.)
여기 콜백을 호출하는 예제가 있습니다:
#![allow(unused)] fn main() { struct Inspector<T>(T, &'static str, Box<for <'r> fn(&'r T) -> String>); impl<T> Drop for Inspector<T> { fn drop(&mut self) { // 예를 들어 만약 `T`가 `&'a _`라면, `self.2` 호출이 빌린 데이터를 접근할 수 있습니다. println!("Inspector({}, {})는 자신도 모르게 파기된 데이터를 봅니다.", (self.2)(&self.0), self.1); } } }
이것은 트레잇 메서드를 호출하는 예제입니다:
#![allow(unused)] fn main() { use std::fmt; struct Inspector<T: fmt::Display>(T, &'static str); impl<T: fmt::Display> Drop for Inspector<T> { fn drop(&mut self) { // 예를 들어 만약 `T`가 `&'a _`이면, 밑에 숨겨진 `<T as Display>::fmt` 호출은 // 빌린 데이터를 접근할 수 있습니다. println!("Inspector({}, {})는 자신도 모르게 파기된 데이터를 봅니다.", self.0, self.1); } } }
그리고 당연히, 이런 접근들은 소멸자에 의해 호출되는 다른 어떤 메서드 안에 숩겨져 있을 수도 있습니다, 꼭 직접 쓰여지지 않고도요.
&'a u8
이 소멸자에서 접근되는 위의 모든 경우에서, #[may_dangle]
속성을 추가하면 그 타입은 대여 검사기가 잡지 않을 오용을 막기 힘들어지고, 대혼란을 야기할 수 있습니다. 이 속성은 사용하지 않는 게 좋겠네요.
해제 순서에 관하여
구조체 안의 필드의 해제 순서는 정의되어 있지만, 이것에 의존하는 것은 불안정하고 애매합니다. 해제 순서가 중요할 때에는,
ManuallyDrop
타입을 대신 쓰는 것이 좋습니다.
해제 검사기에 대해서는 이게 전부인가요?
사실 우리가 불안전한 코드를 작성할 때, 보통은 해제 검사기를 전혀 신경쓰지 않아도 됩니다. 하지만 우리가 신경써야 하는 한 가지 특수한 경우가 있는데, 이것에 관해서는 다음 섹션에서 살펴보겠습니다.
PhantomData
불안전한 코드와 작업을 하다 보면, 우리는 종종 타입이나 수명이 구조체와 논리적으로 연관되어 있지만, 실제로 그 필드의 일부분은 아닌 상황을 마주할 수 있습니다. 이런 상황은 보통 수명인 경우가 많은데요, 예를 들어 &'a [T]
를 위한 Iter
는 (거의) 다음과 같이 정의되어 있습니다:
#![allow(unused)] fn main() { struct Iter<'a, T: 'a> { ptr: *const T, end: *const T, } }
하지만 'a
가 구조체의 본문에 쓰이지 않았기 때문에, 이 수명은 무제한이 됩니다. 이것이 역사적으로 초래해왔던 문제들 때문에, 무제한 수명과 이를 사용하는 타입은 구조체 선언에서 금지되었습니다. 따라서 우리는 어떻게든 이 타입들을 구조체 안에서 참조해야 합니다.
이것을 올바르게 하는 것은 올바른 변성과 해제 검사에 있어서 필수적입니다.
우리는 이것을 특별한 표시 타입인 PhantomData
를 통해서 합니다. PhantomData
는 공간을 차지하지 않지만, 컴파일러의 분석을 위해 주어진 타입의 필드를 흉내냅니다. 이 방식은 우리가 원하는 변성을 직접 타입 시스템에 말하는 것보다 더 오류에 견고하다고 평가되었습니다. 또한 이 방식은 자동 트레잇과 해제 검사에 필요한 정보 등의 유용한 것들을 컴파일러에게 제공합니다.
Iter
는 논리적으로 여러 개의 &'a T
를 포함하므로, 바로 이렇게 우리는 PhantomData
에게 흉내내라고 할 것입니다:
#![allow(unused)] fn main() { use std::marker; struct Iter<'a, T: 'a> { ptr: *const T, end: *const T, _marker: marker::PhantomData<&'a T>, } }
이렇게만 하면 됩니다. 수명은 제한될 것이고, 반복자는 'a
와 T
에 대해서 공변할 것입니다. 모든 게 그냥 마법처럼 동작할 겁니다.
제네릭 매개변수와 해제 검사
RFC 1238의 도움으로, 우리가 이런 코드를 쓴다면:
#![allow(unused)] fn main() { struct Vec<T> { data: *const T, // 변성을 위한 `*const` len: usize, cap: usize, } #[cfg(any())] impl<T> Drop for Vec<T> { /* … */ } }
이 impl<T> Drop for Vec<T>
의 존재가 러스트로 하여금 Vec<T>
가 T
타입의 값들을 소유한다고 (더 정확히는: Drop
구현에서 T
타입의 값들을 사용할 수 있다고) 간주하게 만들고, 따라서 러스트는 Vec<T>
가 해제될 때
T
타입의 값들이 달랑거리는 것을 허용하지 않을 것입니다.
따라서 어떤 타입이 Drop impl
을 가지고 있다면, 추가적으로 _owns_T: PhantomData<T>
필드를 선언하는 것은 불필요하고 아무것도 달성하지 않습니다, 해제 검사기가 볼 때에는요 (변성과 자동 트레잇들에서는 영향을 줍니다).
특수한 경우: 만약
PhantomData
를 포함하는 타입이 그 자체로는Drop
구현이 전혀 없지만,Drop
구현이 있는 다른 필드를 포함한다면, 여기에 명시된 해제 검사기/#[may_dangle]
사항들이 적용될 것입니다: 포함하는 타입이 범위 밖으로 벗어날 때,PhantomData<T>
필드는T
타입이 해제되어도 괜찮도록 할 것입니다.
하지만 이런 상황은 때때로 과도하게 제한된 코드로 이어질 수 있습니다. 바로 그래서 표준 라이브러리는 불안정하고 unsafe
한 속성을 써서 바로 이 문서에서 경고했던, 구식의 "수동" 해제 검사 방식으로 돌아가는 겁니다:
#[may_dangle]
속성으로요.
예외: 표준 라이브러리의 특수한 경우와 불안정한 #[may_dangle]
이 섹션은 당신이 자신의 라이브러리 코드만을 작성한다면 넘어가도 됩니다. 하지만 표준 라이브러리가 실제 Vec
정의를 가지고 무엇을 하는지 궁금하다면, 건전성을 위해 여전히 _owns_T: PhantomData<T>
가 필요하다는 것을
알아차릴 겁니다.
그 이유를 보려면 클릭하세요
다음의 예제를 생각해 봅시다:
fn main() { let mut v: Vec<&str> = Vec::new(); let s: String = "Short-lived".into(); v.push(&s); drop(s); } // <- `v` 는 여기서 해제됩니다
정석적으로 impl<T> Drop for Vec<T> {
를 정의하면, 위의 코드는 부정됩니다.
확실히 이런 경우에서는, 우리는 Vec<&'s str>
, 즉 str
의 's
만큼 사는 레퍼런스들의 벡터를 가지고 있습니다. 하지만 let s: String
에서는, 이것이 Vec
보다 먼저 해제되고,
impl<'s> Drop for Vec<&'s str> {
의 정의가 사용됩니다.
이것이 의미하는 것은 만약 이런 Drop
구현이 사용된다면, 파기된, 혹은 달랑거리는 수명 's
로 작업을 할 것이라는 점입니다. 하지만 이것은 함수 시그니처에 있는 모든 러스트 레퍼런스는 기본적으로 달랑거리지 않고 역참조해도 문제가 없다는
러스트 규칙에 반대됩니다.
따라서 러스트는 보수적으로 이 코드를 부정할 수밖에 없습니다.
그런데 실제 Vec
의 경우에서, Drop
구현은 &'s str
에 대해 신경쓰지 않는데, 이는 &'s str
이 따로 Drop
구현이 없기 때문입니다: Vec
의 Drop
구현은 그저 버퍼를 해제하고 싶을 뿐이죠.
즉, Vec
의 경우를 특별하게 구분해서, 또는 Vec
의 특수한 성질을 이용해서 위의 코드가 컴파일되면 좋겠네요: Vec
이 가지고 있는 &'s str
들을 해제될 때 사용하지 않도록 약속할 수도 있겠어요.
이 약속은 #[may_dangle]
로 표현될 수 있는 unsafe
한 약속입니다:
unsafe impl<#[may_dangle] 's> Drop for Vec<&'s str> { /* … */ }
아니면 좀더 일반적으로 표현하자면:
unsafe impl<#[may_dangle] T> Drop for Vec<T> { /* … */ }
이것이 러스트의 해제 검사기가 해제되는 값의 타입 매개변수가 달랑거리지 않도록 하는, 보수적인 추측에서 탈출하도록 하는 unsafe
한 방법입니다.
표준 라이브러리와 같이 이렇게 했다면, 우리는 T
가 자체의 Drop
구현이 있는 경우를 조심해야 합니다. 이 경우에는, &'s str
를 struct PrintOnDrop<'s>(&'s str);
로 바꾸는 것을 상상해 봅시다.
이 구조체는 자체의 Drop
구현에서 내부의 &'s str
를 역참조하여 화면에 출력할 것입니다.
확실히 버퍼를 해제하기 전에 Drop for Vec<T> {
는, 내부의 각 T
들이 Drop
구현이 있을 때, 각 T
들을 해제시켜야 합니다. PrintOnDrop<'s>
의 경우에 Drop for Vec<PrintOnDrop<'s>>
는
버퍼를 해제하기 전에 PrintOnDrop<'s>
의 원소들을 해제시켜야 합니다.
따라서 우리가 's
가 #[may_dangle]
하다고 말할 때, 이것은 심하게 모호하게 말했던 것입니다. 우리는 대신 이렇게 말해야 할 것입니다: "'s
는 Drop
구현에 구속받지 않는 한에서 달랑거릴 수도 있습니다".
혹은 더 일반적으로 이렇게요: "T
는 Drop
구현에 구속받지 않는 한에서 달랑거릴 수도 있습니다". 이런 "예외의 예외"는 우리가 T
를 소유할 때마다 발생하는 흔한 현상입니다. 이래서 러스트의 #[may_dangle]
은
이런 예외 상황에 대해 알고, 따라서 구조체의 필드들에 제네릭 매개변수가 소유되는 때에 비활성화될 것입니다.
따라서 표준 라이브러리는 이렇게 결론을 내립니다:
#![allow(unused)] fn main() { #[cfg(any())] // 우리는 `Vec`을 해제할 때 `T`를 사용하지 않도록 약속합니다… unsafe impl<#[may_dangle] T> Drop for Vec<T> { fn drop(&mut self) { unsafe { if mem::needs_drop::<T>() { /* … 이 경우는 제외하고요, 어떤 경우냐면 … */ ptr::drop_in_place::<[T]>(/* … */); } // … dealloc(/* … */) // … } } } struct Vec<T> { // … `Vec`이 `T`의 원소들을 소유하고, // 따라서 해제될 때 `T`의 원소들을 해제시킬 때 말이죠! _owns_T: core::marker::PhantomData<T>, ptr: *const T, // 변성을 위한 `*const`입니다 (하지만 이것이 *그 자체로* `T`의 소유권을 나타내는 것은 아닙니다) len: usize, cap: usize, } }
할당된 메모리를 소유하는 생 포인터는 너무나 흔한 패턴이여서, 표준 라이브러리는 이것을 위한 도구인 Unique<T>
를 만들었는데, 이것은:
- 변성을 위해
*const T
를 안에 포함합니다 PhantomData<T>
를 포함합니다- 마치
T
가 포함된 것처럼Send
/Sync
를 자동으로 구현합니다 - 널 포인터 최적화를 위해 포인터를
NonZero
로 표시합니다
PhantomData
패턴의 표
여기 PhantomData
가 사용될 수 있는 모든 경우의 놀라운 표가 있습니다:
흉내내는 타입 | 'a 의 변성 | T 의 변성 | Send /Sync (or lack thereof) | 해제 구현에서 'a 나 T 의 달랑거림(예: #[may_dangle] Drop ) |
---|---|---|---|---|
PhantomData<T> | - | 공변 | 전달 | 불가 ("T 를 소유") |
PhantomData<&'a T> | 공변 | 공변 | T : Sync 이면 Send + Sync | 가능 |
PhantomData<&'a mut T> | 공변 | 무변 | 전달 | 가능 |
PhantomData<*const T> | - | 공변 | !Send + !Sync | 가능 |
PhantomData<*mut T> | - | 무변 | !Send + !Sync | 가능 |
PhantomData<fn(T)> | - | 반변 | Send + Sync | 가능 |
PhantomData<fn() -> T> | - | 공변 | Send + Sync | 가능 |
PhantomData<fn(T) -> T> | - | 무변 | Send + Sync | 가능 |
PhantomData<Cell<&'a ()>> | 무변 | - | Send + !Sync | 가능 |
- 주의: 자동으로 구현되는
Unpin
트레잇을 구현하지 않으려면 따로 선언된PhantomPinned
타입을 사용해야 합니다.
대여 쪼개기
가변 레퍼런스의 상호 배제 규칙은 복잡한 구조와 작업할 때 굉장히 제한적일 수 있습니다. 대여 검사기(즉 borrowck)는 기본적인 내용은 이해하지만, 금세 쉽게 넘어질 겁니다. 이것은 한 구조체의 다른 필드들을 빌리는 것이 가능하다는 것은 알 정도로 구조체를 이해합니다. 따라서 이 코드는 오늘날 잘 작동합니다:
#![allow(unused)] fn main() { struct Foo { a: i32, b: i32, c: i32, } let mut x = Foo {a: 0, b: 0, c: 0}; let a = &mut x.a; let b = &mut x.b; let c = &x.c; *b += 1; let c2 = &x.c; *a += 10; println!("{} {} {} {}", a, b, c, c2); }
하지만 borrowck는 배열이나 슬라이스는 전혀 이해하지 못해서, 이 코드는 동작하지 않습니다:
#![allow(unused)] fn main() { let mut x = [1, 2, 3]; let a = &mut x[0]; let b = &mut x[1]; println!("{} {}", a, b); }
error[E0499]: cannot borrow `x[..]` as mutable more than once at a time
--> src/lib.rs:4:18
|
3 | let a = &mut x[0];
| ---- first mutable borrow occurs here
4 | let b = &mut x[1];
| ^^^^ second mutable borrow occurs here
5 | println!("{} {}", a, b);
6 | }
| - first borrow ends here
error: aborting due to previous error
borrowck가 이런 간단한 경우를 이해할 수 있다는 것은 좋지만, borrowck가 트리 같은 일반적인 컨테이너 타입들에서 "다른 부분"이라는 개념을 이해할 가능성은 거의 없습니다, 특히 다른 키들이 같은 값에 대응할 때는요.
borrowck에게 우리가 하는 작업이 괜찮다는 것을 "가르쳐 주기" 위해, 우리는 불안전한 코드로 내려와야 합니다. 예를 들어 가변 슬라이스는 슬라이스를 소비하고 두 개의 가변 슬라이스를 반환하는 split_at_mut
함수를 정의합니다.
하나는 인덱스의 왼쪽에 있는 모든 것의 슬라이스이고, 다른 하나는 인덱스의 오른쪽에 있는 모든 것의 슬라이스입니다. 직관적으로 우리는 이것이 안전하다는 것을 아는데, 이는 슬라이스가 서로 겹치지 않기 때문입니다. 하지만 이것을 구현하는 것은
어느 정도의 불안전성을 요구합니다:
#![allow(unused)] fn main() { use std::slice::from_raw_parts_mut; struct FakeSlice<T>(T); impl<T> FakeSlice<T> { fn len(&self) -> usize { unimplemented!() } fn as_mut_ptr(&mut self) -> *mut T { unimplemented!() } pub fn split_at_mut(&mut self, mid: usize) -> (&mut [T], &mut [T]) { let len = self.len(); let ptr = self.as_mut_ptr(); unsafe { assert!(mid <= len); (from_raw_parts_mut(ptr, mid), from_raw_parts_mut(ptr.add(mid), len - mid)) } } } }
이것은 사실 조금 애매합니다. 같은 값에 두 개의 &mut
을 만드는 일이 없도록, 우리는 생 포인터를 통하여 명시적으로 새로운 슬라이스를 만듭니다.
그러나 더욱 애매한 것은 가변 레퍼런스를 반환하는 반복자들이 어떻게 작동하느냐 하는 것입니다. Iterator
트레잇은 다음과 같이 정의되어 있습니다:
#![allow(unused)] fn main() { trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } }
이 정의에 의하면, Self::Item
은 self
와 어떤 관련도 없습니다. 이것이 의미하는 것은 우리가 next
를 여러 번 연속으로 호출한 후, 결과들을 동시에 기다리는 것이 가능하다는 겁니다.
이것은 값을 넘겨주는 반복자들에게는 아무 영향도 없습니다, 정확히 이런 의미를 가지거든요. 불변 레퍼런스를 반환하는 반복자들에게도 문제 없습니다, 같은 값에 임의의 많은 레퍼런스들이 있는 것을 허용하기 때문이죠
(비록 반복자가 공유되는 값과 다른 객체여야 하지만요).
하지만 가변 레퍼런스들이 이것을 망칩니다. 처음에 보기에는, 이 API와 전혀 호환될 것 같지 않은데, 이는 같은 객체에 여러 개의 가변 레퍼런스를 만들 것이기 때문입니다!
그러나 실제로는 잘 동작하는데, 바로 반복자들이 일회용 객체들이기 때문입니다. IterMut
이 반환하는 모든 것은 최대 1번 반환될 것이므로, 우리는 같은 데이터 조각에 여러 개의 가변 레퍼런스를 반환하는 일은 없을 겁니다.
놀라울지는 모르겠지만, 가변 반복자들은 많은 타입들의 경우에 구현하는 데에 불안전한 코드가 필요하지 않습니다!
예를 들어 단일 링크드 리스트입니다:
fn main() {} type Link<T> = Option<Box<Node<T>>>; struct Node<T> { elem: T, next: Link<T>, } pub struct LinkedList<T> { head: Link<T>, } pub struct IterMut<'a, T: 'a>(Option<&'a mut Node<T>>); impl<T> LinkedList<T> { fn iter_mut(&mut self) -> IterMut<T> { IterMut(self.head.as_mut().map(|node| &mut **node)) } } impl<'a, T> Iterator for IterMut<'a, T> { type Item = &'a mut T; fn next(&mut self) -> Option<Self::Item> { self.0.take().map(|node| { self.0 = node.next.as_mut().map(|node| &mut **node); &mut node.elem }) } }
가변 슬라이스입니다:
fn main() {} use std::mem; pub struct IterMut<'a, T: 'a>(&'a mut[T]); impl<'a, T> Iterator for IterMut<'a, T> { type Item = &'a mut T; fn next(&mut self) -> Option<Self::Item> { let slice = mem::take(&mut self.0); if slice.is_empty() { return None; } let (l, r) = slice.split_at_mut(1); self.0 = r; l.get_mut(0) } } impl<'a, T> DoubleEndedIterator for IterMut<'a, T> { fn next_back(&mut self) -> Option<Self::Item> { let slice = mem::take(&mut self.0); if slice.is_empty() { return None; } let new_len = slice.len() - 1; let (l, r) = slice.split_at_mut(new_len); self.0 = l; r.get_mut(0) } }
그리고 이진 트리입니다:
fn main() {} use std::collections::VecDeque; type Link<T> = Option<Box<Node<T>>>; struct Node<T> { elem: T, left: Link<T>, right: Link<T>, } pub struct Tree<T> { root: Link<T>, } struct NodeIterMut<'a, T: 'a> { elem: Option<&'a mut T>, left: Option<&'a mut Node<T>>, right: Option<&'a mut Node<T>>, } enum State<'a, T: 'a> { Elem(&'a mut T), Node(&'a mut Node<T>), } pub struct IterMut<'a, T: 'a>(VecDeque<NodeIterMut<'a, T>>); impl<T> Tree<T> { pub fn iter_mut(&mut self) -> IterMut<T> { let mut deque = VecDeque::new(); self.root.as_mut().map(|root| deque.push_front(root.iter_mut())); IterMut(deque) } } impl<T> Node<T> { pub fn iter_mut(&mut self) -> NodeIterMut<T> { NodeIterMut { elem: Some(&mut self.elem), left: self.left.as_mut().map(|node| &mut **node), right: self.right.as_mut().map(|node| &mut **node), } } } impl<'a, T> Iterator for NodeIterMut<'a, T> { type Item = State<'a, T>; fn next(&mut self) -> Option<Self::Item> { match self.left.take() { Some(node) => Some(State::Node(node)), None => match self.elem.take() { Some(elem) => Some(State::Elem(elem)), None => match self.right.take() { Some(node) => Some(State::Node(node)), None => None, } } } } } impl<'a, T> DoubleEndedIterator for NodeIterMut<'a, T> { fn next_back(&mut self) -> Option<Self::Item> { match self.right.take() { Some(node) => Some(State::Node(node)), None => match self.elem.take() { Some(elem) => Some(State::Elem(elem)), None => match self.left.take() { Some(node) => Some(State::Node(node)), None => None, } } } } } impl<'a, T> Iterator for IterMut<'a, T> { type Item = &'a mut T; fn next(&mut self) -> Option<Self::Item> { loop { match self.0.front_mut().and_then(|node_it| node_it.next()) { Some(State::Elem(elem)) => return Some(elem), Some(State::Node(node)) => self.0.push_front(node.iter_mut()), None => if let None = self.0.pop_front() { return None }, } } } } impl<'a, T> DoubleEndedIterator for IterMut<'a, T> { fn next_back(&mut self) -> Option<Self::Item> { loop { match self.0.back_mut().and_then(|node_it| node_it.next_back()) { Some(State::Elem(elem)) => return Some(elem), Some(State::Node(node)) => self.0.push_back(node.iter_mut()), None => if let None = self.0.pop_back() { return None }, } } } }
이 모든 것들은 완벽하게 안전하고 안정적인 러스트에서 동작합니다! 이것은 우리가 봤던 간단한 구조체의 경우를 넘어섭니다: 러스트는 가변 레퍼런스를 안전하게 그 필드들로 쪼갤 수 있다는 것을 이해합니다.
그 다음 우리는 영구적으로 레퍼런스를 소비하는 코드를 Option
으로 짜게 됩니다 (혹은 슬라이스의 경우, 빈 슬라이스로 바꾸는 식으로요).
타입 변환
결국 모든 것은 어딘가에 있는 비트 덩어리일 뿐이고, 타입 시스템은 우리가 그 비트들을 잘 쓰게 하기 위해 존재할 뿐입니다. 비트들에 타입을 입히는 것에는 두 가지 흔한 문제점이 있습니다: 비트들 그대로 다른 타입으로 재해석해야 하는 것과, 다른 타입에서 동일한 의미를 가지도록 비트들을 바꿔야 하는 경우입니다. 러스트가 중요한 속성들을 타입 시스템에 녹여내는 것을 권장하기 때문에, 이런 문제들은 매우 널리 퍼지는 문제들입니다. 따라서 러스트는 이런 종류의 문제들을 해결하는 몇 가지 방법들을 제공합니다.
먼저 우리는 안전한 러스트가 값을 재해석하게 해주는 방법들을 살펴보겠습니다. 가장 흔한 방법은 값을 작은 부분으로 해체시킨 후 그것들로 새 타입을 만드는 것입니다. 예를 들면:
#![allow(unused)] fn main() { struct Foo { x: u32, y: u16, } struct Bar { a: u32, b: u16, } fn reinterpret(foo: Foo) -> Bar { let Foo { x, y } = foo; Bar { a: x, b: y } } }
하지만 이것은, 좋게 말해도, 짜증납니다. 흔한 변환들에 있어서 러스트는 좀더 효율적이고 편리한 방식들을 제공합니다.
강제 변환
타입들은 특정한 상황들에서는 묵시적으로 강제 변환될 수 있습니다. 이런 변화들은 일반적으로 타입 시스템을 약화시키는 것인데, 주로 포인터와 수명에 초점이 맞춰져 있습니다. 이런 것들은 주로 많은 상황에서 러스트가 "그냥 잘 작동"하도록 하기 위하여 존재하며, 꽤나 위험성이 없습니다.
모든 강제 변환의 종류에 대해서는 참조서의 강제 변환 타입 섹션을 보세요.
트레잇을 매칭할 때는 강제 변환을 실행하지 않는다는 것을 유의하세요 (수신자를 위한 경우는 제외하고요, 다음 페이지를 보세요). 만약 어떤 타입 U
를 위한 impl
이 있고 T
가 U
로 강제 변환된다면, T
를 위한 구현으로 인정되지는 않습니다.
예를 들어 다음의 코드는 타입 검사를 통과하지 못할 텐데, t
를 &T
로 강제 변환해도 괜찮고 &T
를 위한 impl
이 있는데도 그렇습니다:
trait Trait {} fn foo<X: Trait>(t: X) {} impl<'a> Trait for &'a i32 {} fn main() { let t: &mut i32 = &mut 0; foo(t); }
이는 다음의 에러를 내뱉습니다:
error[E0277]: the trait bound `&mut i32: Trait` is not satisfied
--> src/main.rs:9:9
|
3 | fn foo<X: Trait>(t: X) {}
| ----- required by this bound in `foo`
...
9 | foo(t);
| ^ the trait `Trait` is not implemented for `&mut i32`
|
= help: the following implementations were found:
<&'a i32 as Trait>
= note: `Trait` is implemented for `&i32`, but not for `&mut i32`
점 연산자
점 연산자는 타입을 변환하기 위해 많은 마법을 사용할 겁니다. 타입이 맞을 때까지 자동 레퍼런싱, 자동 역참조, 강제 변환을 수행하겠죠. 메서드 조회의 자세한 작동 방식은 여기에 정의되어 있지만, 기본적인 절차를 여기서 간단하게 설명하겠습니다.
어떤 수신자(self
, &self
또는 &mut self
매개변수)가 있는 함수 foo
가 있다고 해 봅시다. 만약 우리가 value.foo()
를 호출하면, 컴파일러는 이 함수의 올바른 구현을 호출하기 전에 Self
가 어떤 타입인지 밝혀내야 합니다. 이 예제에서는 value
가 T
타입이라고 하겠습니다.
우리는 완전 정식화 문법을 써서 우리가 어떤 타입의 함수를 호출하는지를 명확히 하겠습니다.
- 먼저 컴파일러는
T::foo(value)
를 직접 호출할 수 있는지 검사합니다. 이것은 "값에 의한" 메서드 호출이라 부릅니다. - 이 함수를 호출할 수 없다면 (예를 들어 함수가 잘못된 타입을 가진다거나
Self
에 대해 트레잇이 구현되지 않았을 경우), 컴파일러는 자동으로 레퍼런스를 추가합니다. 이 말은 컴파일러가<&T>::foo(value)
와<&mut T>::foo(value)
를 시도해 본다는 뜻입니다. 이것은 자동 레퍼런스 메서드 호출이라 부릅니다. - 여기까지의 후보들이 실패했다면 컴파일러는
T
를 역참조하여 다시 시도합니다. 이것은Deref
트레잇을 사용합니다 - 만약T: Deref<Target = U>
라면T
대신U
가 사용됩니다. 만약T
를 역참조할 수 없다면T
의 크기 지정 해제를 시도할 수도 있습니다. 이것은 만약T
가 컴파일 시간에 알려진 크기 매개변수가 있다면, 메서드를 찾기 위해 이것을 "잊어버린다는" 말입니다. 예를 들어, 이런 크기 지정 해제 작업은 배열의 크기를 "잊어버림으로써"[i32; 2]
를[i32]
로 변환할 수 있습니다.
여기 메서드 조회 알고리즘의 예제가 있습니다:
let array: Rc<Box<[T; 3]>> = ...;
let first_entry = array[0];
배열로 가는 길에 돌아가는 길이 이렇게 많은데 컴파일러는 어떻게 실제 array[0]
을 계산할 수 있을까요? 먼저, array[0]
은 그냥 Index
트레잇을 위한 문법적 설탕입니다 - 컴파일러는 array[0]
을 array.index(0)
으로 변환할 겁니다.
이제, 컴파일러는 함수를 호출하기 위해 array
가 Index
를 구현하는지 봅니다.
그럼 컴파일러는 Rc<Box<[T; 3]>>
가 Index
를 구현하는지 보는데, 구현하지 않고, &Rc<Box<[T; 3]>>
나 &mut Rc<Box<[T; 3]>>
도 Index
를 구현하지 않습니다.
여태까지 아무것도 맞지 않았으니, 컴파일러는 Rc<Box<[T; 3]>>
를 Box<[T; 3]>
로 역참조하여 다시 시도합니다. Box<[T; 3]>
, &Box<[T; 3]>
, 그리고 &mut Box<[T; 3]>
는 Index
를 구현하지 않으므로,
컴파일러는 다시 역참조합니다. [T; 3]
과 그의 자동 참조들도 Index
를 구현하지 않습니다. 컴파일러는 [T; 3]
를 역참조할 수 없으므로, 크기 지정을 해제하여, [T]
를 얻어냅니다. 마지막으로, [T]
는 Index
를 구현하므로,
컴파일러는 실제로 index
함수를 호출할 수 있게 됩니다.
점 연산자가 작동하는 좀더 복잡한 다음의 예제를 생각해 봅시다:
#![allow(unused)] fn main() { fn do_stuff<T: Clone>(value: &T) { let cloned = value.clone(); } }
cloned
는 어떤 타입일까요? 먼저, 컴파일러는 값으로 호출할 수 있는지 알아봅니다. value
의 타입은 &T
이고, clone
함수는 fn clone(&T) -> T
의 시그니처를 가지고 있습니다. 컴파일러는 T: Clone
을 알고 있으니,
cloned: T
인 것을 찾아냅니다.
만약 T: Clone
제한이 없어졌다면 무슨 일이 일어날까요? T
를 위한 Clone
구현이 없으므로, 컴파일러는 값으로 호출하지 못할 것입니다. 따라서 컴파일러는 자동 참조로 호출을 시도합니다. 이 경우에는 Self = &T
이므로
함수는 fn clone(&&T) -> &T
의 시그니처를 가지게 됩니다. 컴파일러는 &T: Clone
을 알아차리고, cloned: &T
라고 결론짓습니다.
여기, 자동 참조 동작이 잘 보이지 않는 변화를 만들어내는 데 쓰이는, 다른 예제가 있습니다.
#![allow(unused)] fn main() { use std::sync::Arc; #[derive(Clone)] struct Container<T>(Arc<T>); fn clone_containers<T>(foo: &Container<i32>, bar: &Container<T>) { let foo_cloned = foo.clone(); let bar_cloned = bar.clone(); } }
foo_cloned
와 bar_cloned
는 어떤 타입일까요? 우리는 Container<i32>: Clone
이라는 것을 알기 때문에, 컴파일러는 clone
을 값으로 호출하여 foo_cloned: Container<i32>
를 얻어냅니다. 그러나,
bar_cloned
는 실제로는 &Container<T>
를 타입으로 가지게 됩니다. 확실히 이것은 말이 되지 않습니다 - 우리는 Container
에 #[derive(Clone)]
을 추가했으므로, Container
는 Clone
을 구현해야 합니다!
좀더 가까이 보자면, derive
매크로에 의해 생성된 코드는 (대강) 다음과 같습니다:
impl<T> Clone for Container<T> where T: Clone {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}
파생된 Clone
구현은 T: Clone
일 때만 정의되어 있으며, 따라서 일반적인 T
에 대해 Container<T>: Clone
구현은 없는 것입니다. 그럼 컴파일러는 &Container<T>
가 Clone
을 구현하는지 봅니다.
&Container<T>
가 Clone
을 구현하므로, 컴파일러는 clone
이 자동 참조로 호출된다고 결론 내리고, 따라서 bar_cloned
는 &Container<T>
의 타입을 갖게 됩니다.
우리는 T: Clone
을 요구하지 않는 Clone
을 수동으로 구현함으로써 이 문제를 해결할 수 있습니다:
impl<T> Clone for Container<T> {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}
이제 타입 검사기는 bar_cloned: Container<T>
라고 결론짓습니다.
변형
변형은 강제 변환을 포함하는 더 큰 개념입니다: 모든 강제 변환은 변형으로 명시적으로 호출할 수 있습니다. 하지만 어떤 변환들은 변형을 필요로 합니다. 강제 변환은 흔하고 보통은 위험하지 않지만, 이런 "진짜 변형"은 희귀하고, 위험할 수 있습니다.
그런 면에서, 변형은 명시적으로 as
키워드를 사용해서 호출해야 합니다: expr as Type
.
모든 변형과 그 의미들은 참조서에서 전체 목록을 볼 수 있습니다.
변형의 안전성
진정한 변형은 일반적으로 생 포인터들과 기초적인 수 타입들 주위를 맴돕니다. 위험하긴 하지만, 이 변형들은 실행 시에는 실패할 수 없습니다.
만약 어떤 변형 작업이 어떤 특수한 경우를 발생시킨다면 이런 경우가 일어났다는 표시는 주어지지 않을 것입니다. 그 변형 작업은 그냥 성공하게 됩니다.
그런 점에서 변형은 타입 단계에서 올바라야 하는데, 그렇지 않으면 컴파일 때에 방지될 것입니다. 예를 들어, 7u8 as bool
은 컴파일되지 않을 것입니다.
이러한 것들을 보았을 때, 변형은 unsafe
하지는 않습니다, 왜냐하면 일반적으로 그 자체로는 메모리 안정성을 위배할 수 없기 때문이죠. 예를 들어, 어떤 정수를 생 포인터로 변환하는 것은 매우 쉽게 다른 끔찍한 일들로 이어질 수 있습니다.
하지만 그 포인터를 만드는 것 자체는 안전한데, 생 포인터를 실제로 사용하는 작업이 이미 unsafe
로 표시되었기 때문입니다.
변형에 대한 몇 가지 주의사항
생 슬라이스를 변형할 때의 길이
생 슬라이스를 변형할 때 길이가 조정되지 않는다는 점을 주의하세요: *const [u16] as *const [u8]
은 원래 메모리의 절반만 포함하는 슬라이스를 만들어냅니다.
전이성
변형은 전이적이지 않습니다, 다시 말해, e as U1 as U2
가 올바른 식이라고 해도, e as U2
는 꼭 올바르지는 않을 수 있다는 겁니다.
변질
저리 비켜 타입 시스템! 우린 이 비트들을 재해석하거나 죽을 것이다! 비록 이 책이 불안전한 것들을 하는 것에 대한 내용이지만, 이 섹션에 소개된 내용 말고 다른 방법을 깊게 생각해 봐야 한다고 충분히 강조할 수가 없네요. 이것은 진짜, 진실로, 러스트에서 할 수 있는 가장 끔찍하게 불안전한 것입니다. 여기 있는 글들은 단지 치실 같은 역할일 뿐입니다.
mem::transmute<T, U>
는 T
타입의 값을 받아서 U
타입의 값이 되도록 재해석합니다. 유일한 제한은 T
와 U
가 같은 크기를 가지는 것이 보장되어야 한다는 겁니다.
이것으로 미정의 동작을 일으키는 방법들은 충격적입니다.
-
첫번째로, 그리고 가장 중요하게 말할 것은, 어떤 타입의 값이든 올바르지 않은 상태로 만드는 것은 정말로 예상할 수 없는 변덕스러운 혼돈을 초래할 것입니다.
3
을bool
로 변질하지 마세요. 그bool
로 아무것도 하지 않더라도요. 그냥 하지 마세요. -
변질은 반환 타입이 타입 변수입니다. 만약 반환 타입을 명시하지 않는다면 타입 추론을 만족하기 위해 이상한 타입을 반환할 수 있습니다.
-
&
를&mut
로 변질하는 것은 미정의 동작입니다. 이것을 사용하는 몇몇 부분이 안전하게 보일 수 있지만, 러스트가 최적화를 할 때 불변 레퍼런스는 그 수명 동안 변하지 않는다는 것이라 가정하고, 이런 변질은 그런 가정과 정면으로 충돌할 수 있다는 것을 주의하세요. 따라서:&
를&mut
로 변질하는 것은 언제나 미정의 동작입니다.- 안됩니다, 하면 안돼요.
- 아뇨, 당신은 특별하지 않습니다.
-
레퍼런스로 변질할 때 명확히 수명을 제시하지 않으면 무제한 수명이 됩니다.
-
서로 다른 복합 타입들 간에 변질할 때, 타입들이 똑같이 정렬되어 있다는 것을 확실히 해야 합니다! 만약 다르게 정렬되어 있다면, 잘못된 필드가 잘못된 값으로 채워지고, 그러면 당신을 불행하게 만들고 또한 미정의 동작이 발생할 수 있습니다 (위를 보세요).
그럼 똑같이 정렬되어 있는지 어떻게 알까요?
repr(C)
타입과repr(transparent)
타입들에 대해서는, 어떻게 정렬되는지가 정확하게 정의되어 있습니다. 하지만 당신의 흔해 빠진repr(Rust)
타입은 그렇지가 않습니다. 같은 제네릭 타입의 다른 인스턴스들조차도 완전히 다르게 정렬될 수 있습니다.Vec<i32>
와Vec<u32>
는 필드들을 같은 순서로 배치했을 수도 있고, 아닐 수도 있습니다. 데이터가 어떻게 배치되는지, 어느 것이 실제로 보장되었는지 혹은 아닌지에 대한 세부 사항은 불안전 코드 가이드라인에서 작업 중입니다.
mem::transmute_copy<T, U>
는 이것보다 더하게 엄청나게 불안전한 짓을 어떻게 해 냅니다. 이것은 &T
에서 size_of<U>
만큼의 바이트를 복사해서 그것을 U
라고 해석합니다.
mem::transmute
가 가지고 있던 크기 검사는 없습니다 (앞부분만 복사하는 것이 올바를 수도 있기 때문입니다), U
가 T
보다 크면 미정의 동작이 일어나지만 말이죠.
그리고 당연히 여러분은 이 함수들의 기능을 생 포인터 변형이나 union
을 이용해서 전부 사용할 수 있지만, 타입 검사나 다른 기본적인 검사는 제공되지 않습니다. 생 포인터 변형과 union
은 위의 규칙들을 마법적으로 피하지는 않습니다.
초기화되지 않은 메모리를 가지고 작업하기
러스트 프로그램에서 실행 시간에 할당되는 메모리는 모두 초기화되지 않은 상태로 그 삶을 시작합니다. 이 상태에서 메모리의 값은 그 메모리 위치에 들어가도록 되어 있는 타입의 올바른 값인지도 불분명한, 중간 상태의 비트 덩어리입니다. 이 메모리를 어떤 타입의 값으로든 해석하려고 하는 것은 미정의 동작을 초래합니다. 하지 마세요.
러스트는 초기화되지 않는 메모리를 가지고 작업하는 검사되는 (안전한) 방법과 검사되지 않는 (불안전한) 방법의 장치들을 제공합니다.
검사받는 초기화되지 않은 메모리
C와 같이, 러스트에서 모든 스택 변수는 값이 배정되기 전까지 초기화되지 않은 상태입니다. C와 다르게, 러스트는 값을 배정하기 전에 그 변수들을 읽는 것을 컴파일 때 방지합니다:
fn main() { let x: i32; println!("{}", x); }
|
3 | println!("{}", x);
| ^ use of possibly uninitialized `x`
이것은 기본적인 경우 검사에 기반한 것입니다: x
가 처음 쓰이기 전에 모든 경우에서 x
에 값을 할당해야 합니다. 짧게 말하자면, 우리는 "x
가 초기화됐다" 혹은 "x
가 미초기화됐다"고도 말합니다.
흥미롭게도, 만약 늦은 초기화를 할 때 모든 경우에서 변수에 값을 정확히 한 번씩 할당한다면, 러스트는 변수가 가변이어야 한다는 제약을 걸지 않습니다. 하지만 이 분석은 상수 분석 같은 것을 이용하지 못합니다. 따라서 이 코드는 컴파일되지만:
fn main() { let x: i32; if true { x = 1; } else { x = 2; } println!("{}", x); }
이건 아닙니다:
fn main() { let x: i32; if true { x = 1; } println!("{}", x); }
|
6 | println!("{}", x);
| ^ use of possibly uninitialized `x`
이 코드는 컴파일됩니다:
fn main() { let x: i32; if true { x = 1; println!("{}", x); } // 초기화되지 않는 경우가 있지만 신경쓰지 않습니다 // 그 경우에서는 그 변수를 사용하지 않기 때문이죠 }
당연하게도, 이 분석이 진짜 값을 신경쓰는 건 아니지만, 프로그램 구조와 의존성에 대한 비교적 높은 수준의 이해를 하고 있습니다. 예를 들어, 다음의 코드는 작동합니다:
#![allow(unused)] fn main() { let x: i32; loop { // 러스트는 이 경우가 조건 없이 실행될 거라는 것을 알지 못합니다 // 이 경우는 실제 값에 의존하기 때문이죠 if true { // 하지만 러스트는 이 경우가 정확히 한 번 실행될 거라는 것을 압니다 // 우리가 조건 없이 이 경우를 `break`하기 때문이죠. // 따라서 `x`는 가변일 필요가 없습니다. x = 0; break; } } // 또한 러스트는 `break`에 닿지 않고서는 여기에 도달할 수 없다는 것을 압니다. // 그리고 따라서 여기의 `x`는 반드시 초기화된 상태라는 것도요! println!("{}", x); }
만약 어떤 변수에서 값이 이동한다면, 그 타입이 Copy
가 아닐 경우 그 변수는 논리적으로 미초기화된 상태가 됩니다. 즉:
fn main() { let x = 0; let y = Box::new(0); let z1 = x; // `i32`는 `Copy`이므로 `x`는 여전히 유효합니다 let z2 = y; // `Box`는 `Copy`가 아니므로 `y`는 이제 논리적으로 미초기화 되었습니다 }
하지만 이 예제에서 y
에 값을 다시 할당하려면 y
가 가변이어야 할 겁니다, 안전한 러스트에서 프로그램이 y
의 값이 변했다는 것을 볼 수 있을 테니까요:
fn main() { let mut y = Box::new(0); let z = y; // `Box`는 `Copy`가 아니므로 `y`는 이제 논리적으로 미초기화 되었습니다 y = Box::new(1); // `y`를 재초기화 합니다 }
그게 아니라면 마치 y
가 새로운 변수처럼 보일 테니까요.
해제 표기
전 섹션에서의 예제들은 러스트에 흥미로운 문제를 던져줍니다. 우리는 완전히 안전하게 조건에 따라 메모리 위치를 초기화, 비초기화, 재초기화할 수 있다는 것을 봤습니다. Copy
타입들에게 이것은 그렇게 놀랍지 않습니다, 이 타입의 값은 그냥 비트들일 뿐이기 때문이죠. 하지만 소멸자가 있는 타입들은 이야기가 좀 다릅니다: 이 변수가 값이 할당되거나 범위에서 벗어날 때마다 소멸자를 호출해야 하는지 러스트는 알아야만 하죠. 러스트는 조건부 초기화에서 이것을 어떻게 할까요?
이것이 모든 할당이 걱정해야 하는 문제는 아니라는 것을 염두하세요. 특히, 역참조를 통해 값을 할당하는 것은 무조건적으로 해제되고, let
으로 할당하는 것은 무조건적으로 해제되지 않습니다:
#![allow(unused)] fn main() { let mut x = Box::new(0); // `let`은 변수를 새로 만드므로, 해제할 필요가 없습니다 let y = &mut x; *y = Box::new(1); // `Deref`는 피참조자가 초기화되어 있다고 가정하므로, 항상 해제됩니다 }
이것은 전에 초기화된 변수나 그 필드를 덮어쓸 때 문제인 것입니다.
사실 러스트는 타입이 해제되어야 하는지를 실행 시간에 추적합니다. 변수가 초기화되고 비초기화될 때, 그 변수의 해제 표기가 바뀝니다. 변수가 해제되어야 할 수 있을 때, 실제로 해제되어야 하는지를 결정하기 위해 이 표기를 평가합니다.
당연하게도, 프로그램의 모든 부분에서 값의 초기화 상태가 어떤지를 컴파일할 때 알 수 있는 경우가 대부분입니다. 만약 이 경우라면, 컴파일러는 이론적으로 더 효율적인 코드를 만들어낼 수 있습니다! 예를 들어, 일직선으로 가는 코드는 이런 정적 해제 의미를 가지고 있습니다:
#![allow(unused)] fn main() { let mut x = Box::new(0); // `x`는 비초기화; 그냥 덮어씁니다. let mut y = x; // `y`는 비초기화; 그냥 덮어쓰고 `x`를 비초기화로 만듭니다. x = Box::new(0); // `x`는 비초기화; 그냥 덮어씁니다. y = x; // `y`는 초기화됨; `y`를 해제하고, 덮어쓰고, `x`를 비초기화로 만듭니다! // `y`가 범위 밖으로 벗어납니다; `y`는 초기화 상태였죠; `y`를 해제합니다! // `x`가 범위 밖으로 벗어납니다; `x`는 비초기화 상태였죠; 아무것도 하지 않습니다. }
비슷하게, 모든 경우가 초기화에 있어서 같은 행동을 하는 코드도 정적 해제 의미를 가집니다:
#![allow(unused)] fn main() { let condition = true; let mut x = Box::new(0); // `x`는 비초기화; 그냥 덮어씁니다. if condition { drop(x) // `x`에서 값이 이동했습니다; `x`를 비초기화로 만듭니다. } else { println!("{}", x); drop(x) // `x`에서 값이 이동했습니다; `x`를 비초기화로 만듭니다. } x = Box::new(0); // `x`는 비초기화; 그냥 덮어씁니다. // `x`가 범위 밖으로 벗어납니다; `x`는 초기화 상태였죠; `x`를 해제합니다! }
하지만 다음과 같은 코드는 올바르게 해제하려면 실행 시간에 정보가 필요합니다:
#![allow(unused)] fn main() { let condition = true; let x; if condition { x = Box::new(0); // `x`는 비초기화; 그냥 덮어씁니다. println!("{}", x); } // `x`가 범위 밖으로 벗어납니다; `x`는 비초기화 상태였을 수 있습니다; // 표기를 확인합니다! }
당연히 이 경우에는 정적 해제 의미를 되찾는 것이 보통입니다:
#![allow(unused)] fn main() { let condition = true; if condition { let x = Box::new(0); println!("{}", x); } }
해제 표기는 스택에 있습니다. 예전 러스트 버전에서 해제 표기는 Drop
을 구현하는 타입의 숨겨진 필드 안에 있었습니다.
검사받지 않는 미초기화 메모리
이 규칙의 한 흥미로운 예외는 배열을 가지고 작업할 때입니다. 안전한 러스트는 배열을 부분적으로 초기화하도록 허용하지 않습니다. 배열을 초기화하려면, let x = [val; N]
과 같이 전부 다 같은 값으로 설정하거나,
아니면 let x = [val1, val2, val3]
과 같이 하나하나 나열해서 할당합니다. 불행하게도 이것은 꽤나 융통성이 없습니다, 특히 배열을 좀더 점진적으로, 또는 동적으로 초기화하고 싶을 때 말이죠.
불안전한 러스트는 이런 문제를 해결하기 위해 강력한 도구를 선사합니다: MaybeUninit
이죠. 이 타입은 아직 완전히 초기화되지 않은 메모리를 다루기 위해 사용될 수 있습니다.
MaybeUninit
으로 우리는, 다음과 같이 배열을 원소별로 초기화할 수 있습니다:
#![allow(unused)] fn main() { use std::mem::{self, MaybeUninit}; // 배열의 크기는 손으로 적혀 있지만 바꾸기 쉽습니다 (이 상수의 값만 바꾸면 되니까요). // 하지만 이것은 우리가 배열을 초기화할 때 [a, b, c] 와 같은 문법을 사용할 수 없다는 것을 말합니다, // `SIZE`가 바뀔 때마다 그 구문이 계속 바뀔 테니까요! const SIZE: usize = 10; let x = { // `MaybeUninit`의 미초기화된 배열을 만듭니다. `assume_init`은 안전한데, // 우리가 여기서 초기화했다고 주장하는 것들은 `MaybeUninit`들인데, // 이들은 초기화를 필요로 하지 않기 때문입니다. let mut x: [MaybeUninit<Box<u32>>; SIZE] = unsafe { MaybeUninit::uninit().assume_init() }; // `MaybeUninit`은 범위 밖으로 벗어나도 아무 일도 일어나지 않습니다. // 따라서 `ptr::write` 대신 생 포인터 할당을 이용해도 기존의 미초기화된 값이 해제되지 않습니다. // `Box`는 `panic!`할 수 없으므로 예외 안전성은 걱정할 거리가 못 됩니다. for i in 0..SIZE { x[i] = MaybeUninit::new(Box::new(i as u32)); } // 모든 것이 초기화됐습니다. 배열을 초기화된 타입으로 변질합니다. unsafe { mem::transmute::<_, [Box<u32>; SIZE]>(x) } }; dbg!(x); }
이 코드는 3가지의 단계로 나아갑니다:
-
MaybeUninit<T>
의 배열을 만듭니다. 현재의 러스트 안정 버전으로는 이것을 위해서는 불안전한 코드를 써야 합니다: 우리는 미초기화된 메모리 조각을 가져다가 (MaybeUninit::uninit()
) 그것을 완전히 초기화했다고 주장합니다 (assume_init()
). 이것은 우스꽝스럽게 보입니다, 우리가 이것을 초기화하지는 않았거든요! 이것이 맞는 이유는 배열 전체가 초기화를 필요로 하지 않는MaybeUninit
으로 이루어졌기 때문입니다. 다른 대부분의 타입들에 대해서는,MaybeUninit::uninit().assume_init()
은 그 타입의 잘못된 값을 만들어내고, 그러면 미정의 동작이 튀어나오겠죠. -
배열을 초기화합니다. 이것의 잘 보이지 않는 점은 보통, 우리가
=
를 사용해서 러스트 타입 검사기가 이미 초기화되었다고 판단한 타입에 값을 할당할 때 (x[i]
같은), 좌변에 있던 이전의 값은 해제된다는 겁니다. 이건 재앙이 될 겁니다. 하지만, 이 경우에는 좌변의 타입이MaybeUninit<Box<u32>>
이고, 이것을 해제해 봐야 아무 것도 일어나지 않습니다! 이drop
사항에 관해서는 밑에서 좀더 논의하겠습니다. -
마지막으로, 우리는 배열의 타입에서
MaybeUninit
을 지워야 합니다. 현재의 안정적인 러스트 버전으로는, 이 작업은transmute
를 써야 합니다. 이 변질은 합당한데 이는 메모리 안에서MaybeUninit<T>
은T
와 똑같아 보이기 때문입니다.하지만, 보통은
Container<MaybeUninit<T>>>
는Container<T>
와 똑같아 보이지 않습니다! 만약Container
가Option
이고,T
가bool
이라고 가정할 때,Option<bool>
은bool
이 오직 유효한 2개의 값을 가지고 있다는 것을 이용하지만,Option<MaybeUninit<bool>>
은bool
이 초기화되지 않아도 되기 때문에 그런 작업을 할 수 없습니다.따라서,
MaybeUninit
을 변질해서 타입에서 지워도 되는지는Container
에 따라 다릅니다. 배열에 대해서는 그렇습니다 (그리고 결국 표준 라이브러리도 이것을 알아차리고 적당한 메서드를 제공할 겁니다).
중간에 있는 반복문은 좀더 시간을 들일 가치가 있는데, 특히 할당문과 또한 drop
간의 관계가 그렇습니다. 우리가 만약 이런 코드를 쓴다면:
*x[i].as_mut_ptr() = Box::new(i as u32); // 틀림!
우리는 Box<u32>
를 실제로 덮어쓰게 되고, 미초기화된 데이터가 drop
되며, 이는 엄청난 슬픔과 고통으로 다가올 것입니다.
올바른 대체 방법은, 만약 어떤 이유로 우리가 MaybeUninit::new
를 사용할 수 없다면, ptr
모듈을 사용하는 것입니다.
특히 이 모듈은 기존 값을 해제시키지 않으면서 메모리 위치에 값을 할당할 수 있게 해 주는 3개의 함수를 제공합니다: write
, copy
, 그리고 copy_nonoverlapping
이죠.
ptr::write(ptr, val)
는val
을 가지고ptr
이 가리키는 주소에 옮겨 놓습니다.ptr::copy(src, dest, count)
는count
만큼의T
값들이 차지하는 비트들을src
에서dest
로 복사합니다. (이것은 C의 memmove와 같습니다 -- 다만 매개변수의 순서가 거꾸로입니다!)ptr::copy_nonoverlapping(src, dest, count)
는copy
가 하는 일을 하지만, 두 메모리 영역이 겹치지 않는다는 가정 하에 작업하기 때문에 좀더 빠릅니다. (이것은 C의 memcpy와 같습니다 -- 다만 매개변수의 순서가 거꾸로입니다!)
이 함수들이 오용된다면 심각한 피해를 초래하거나 바로 미정의 동작을 유발할 거라는 것은 두말할 필요가 없겠죠. 이 함수들 자체에 있는 요구사항은 읽고 쓰는 메모리 위치가 메모리가 할당되고 잘 정렬되어 있어야 한다는 것입니다. 하지만, 임의의 비트들을 임의의 메모리 상의 위치에 씀으로써 프로그램이 망가지는 방법은 정말 셀 수가 없습니다!
Drop
을 구현하지 않거나 Drop
타입들을 포함하지 않는 타입에 ptr::write
식의 장난을 치는 것은 걱정할 필요가 없다는 것은 알아 두세요, 러스트는 그것을 알고 그 값들을 해제하지 않을 것이기 때문입니다.
이것이 바로 위의 예제에서 우리가 근거로 삼았던 사실입니다.
하지만 미초기화된 메모리를 가지고 작업할 때, 위의 것 같이 값들이 완전히 초기화되기 전에 러스트가 값들을 해제하려고 시도하지는 않는지 항상 경계해야 합니다.
만약 소멸자가 있다면, 그 변수의 모든 프로그램 상의 경우는 그 범위가 끝나기 전에 값을 초기화해야 합니다. 이것은 코드가 panic!
하는 것도 포함합니다.
MaybeUninit
은 여기서 우리를 조금 도와주는데, 암묵적으로 그 내용물을 해제하지 않기 때문입니다 -
하지만 panic!
이 일어날 경우 이 모든 것이 의미하는 것은 아직 초기화되지 않은 부분들의 이중 해제 대신, 이미 초기화된 부분들의 메모리 누설로 끝난다는 점입니다.
주의할 것은, ptr
메서드들을 사용하려면 우선 초기화하고 싶은 데이터의 생 포인터를 얻어내야 합니다. 초기화되지 않은 데이터에 레퍼런스를 만드는 것은 불법이고, 따라서 생 포인터를 얻을 때는 주의해야 합니다:
T
의 배열에 있어서는, 배열의 인덱스idx
번째를 계산할 때는base_ptr: *mut T
일 때base_ptr.add(idx)
를 사용하면 됩니다. 이것은 메모리에 배열이 어떻게 배치되는지를 이용합니다.- 하지만 구조체의 경우, 일반적으로 우리는 어떻게 배치되어 있는지 알지 못합니다. 또한 우리는
&mut base_ptr.field
를 사용할 수 없는데, 레퍼런스를 만드는 행위이기 때문입니다. 따라서, 생 레퍼런스 문법을 조심스럽게 사용해야 합니다. 이것은 중간의 레퍼런스를 만들지 않고 바로 구조체의 필드를 가리키는 생 포인터를 만들어 냅니다:
#![allow(unused)] fn main() { use std::{ptr, mem::MaybeUninit}; struct Demo { field: bool, } let mut uninit = MaybeUninit::<Demo>::uninit(); // `&uninit.as_mut().field` 는 초기화되지 않은 `bool`에 레퍼런스를 만들어낼 겁니다, // 따라서 **미정의 동작이** 일어납니다! let f1_ptr = unsafe { &raw mut (*uninit.as_mut_ptr()).field }; unsafe { f1_ptr.write(true); } let init = unsafe { uninit.assume_init() }; }
마지막 당부는, 오래된 러스트 코드를 볼 때, 폐기된 mem::uninitialized
함수를 마주칠지도 모릅니다. 이 함수는 스택의 초기화되지 않은 메모리를 처리하는 유일한 방법이었지만,
언어의 다른 부분과 잘 통합되는 것이 불가능하다고 판단되었습니다. 항상 새로운 코드에서는 그 대신 MaybeUninit
을 사용하시고, 기회가 있을 때 오래된 코드를 변환하세요.
초기화되지 않은 메모리를 가지고 작업하는 것에 대한 내용은 이 정도쯤 되겠습니다! 기본적으로 어느 곳에 어떤 것이든 초기화되지 않은 메모리가 전달되는 것은 기대하지 않기 때문에, 만약 조금이라도 초기화되지 않은 메모리를 어딘가에 놓는다면, 매우 조심하세요.
소유권 기반 자원 관리의 위협 (OBRM)
러스트에서는 여러분이 OBRM (혹은 RAII: Resource Acquisition Is Initialization) 을 많이 접할 것입니다. 특히 표준 라이브러리를 사용한다면 말이죠.
간단히 말해 패턴은 이와 같습니다: 자원을 얻으려면 그 자원을 관리하는 객체를 만듭니다. 자원을 반납하려면 단지 객체를 해제하기만 하면 됩니다, 그럼 그 객체가 자원을 알아서 정리해 주죠.
이 패턴이 관리하는 가장 흔한 "자원"은 그냥 메모리입니다. Box
, Rc
, 그리고 std::collection
에 있는 거의 모든 것이 정확히 메모리를 관리하기 위해 만든 도구입니다.
이것은 특히 러스트에서 중요한데, 메모리 관리에 대해서는 따로 의존할 전방위적인 GC도 없기 때문입니다. 이것이 바로 중요한 점입니다, 진짜로요: 러스트는 제어에 관한 것입니다. 하지만 우리는 메모리에만 국한되지는 않습니다.
스레드, 파일, 소켓 등 거의 모든 시스템 자원이 이런 종류의 API를 통해 제공됩니다.
생성자
사용자 정의 타입의 값을 만드는 방법은 오직 하나입니다: 타입의 이름을 적고, 모든 필드들을 한번에 초기화하는 것입니다:
#![allow(unused)] fn main() { struct Foo { a: u8, b: u32, c: bool, } enum Bar { X(u32), Y(bool), } struct Unit; let foo = Foo { a: 0, b: 1, c: false }; let bar = Bar::X(0); let empty = Unit; }
이게 전부입니다. 타입의 값을 만드는 다른 모든 방식은 다른 일을 좀 하고 결국에는 오직 진정한 하나의 생성자를 호출하는, 순수한 함수를 호출하는 것뿐입니다.
C++과 다르게, 러스트는 기본적으로 제공하는 다량의 생성자는 없습니다. 복사 생성자, 기본 생성자, 할당 생성자, 이동 생성자, 혹은 무슨 생성자든 없습니다. 이러는 이유는 여러 가지이지만, 가장 큰 이유는 러스트의 명시적으로 하자는 철학 때문입니다.
이동 생성자는 러스트에서는 의미가 없는데, 타입이 메모리의 위치에 대해서 "신경쓰는" 일이 없도록 하기 때문입니다. 모든 타입은 메모리의 다른 어딘가로 그냥 memcopy
되도록 준비되어야 합니다.
이 뜻은 스택에는 있지만 이동 가능한, 불청객 링크드 리스트는 러스트에서는 존재하지 않는다는 뜻입니다 (안전하게 말이죠).
할당 생성자와 복사 생성자는 마찬가지로, 이동만이 러스트에서 가지는 의미이기 때문입니다. 거의 모든 x = y
는 그냥 y
의 비트들을 x
변수로 옮깁니다.
러스트는 C++의 복사하는 의미를 제공하기 위해 2개의 장치를 제공합니다: Copy
와 Clone
이죠. Clone
은 우리의 복사 생성자와 같은 기능을 하지만, 절대로 암시적으로 호출되지 않습니다.
복사를 원하는 값에 명시적으로 clone
을 호출해야 하죠. Copy
는 Clone
의 특수한 경우로, 그 구현은 그냥 "비트를 그대로 복사합니다".
Copy
타입은 이동될 때 암시적으로 복사가 됩니다, 하지만 Copy
의 정의 때문에 이 의미는 이전의 원본을 비초기화시키지 말라는 것이죠 -- 아무것도 하지 않는 작업입니다.
러스트가 기본 생성자의 기능을 Default
트레잇을 통해서 제공하지만, 이 트레잇이 사용되는 일은 매우 적습니다. 왜냐하면 변수는 암시적으로 초기화되지 않기 때문입니다. Default
는 제네릭 프로그래밍에서야 유용합니다.
타입이 다 밝혀진 경우에서는, 그 타입이 "기본" 생성자를 new
정적 메서드를 통해 제공할 것입니다. 이것은 다른 언어에서의 new
와 관계가 없고, 특별한 의미도 없습니다. 그냥 이름 짓는 관례일 뿐입니다.
TODO: talk about "placement new"?
소멸자
러스트에서는 일반적인 소멸자는 없지만, 러스트에서 제공하는 것은 Drop
트레잇을 통한 완전 자동화된 소멸자입니다. 이 트레잇은 다음의 메서드를 제공합니다:
fn drop(&mut self);
이 메서드는 타입에 하던 일을 끝낼 시간을 줍니다.
drop
이 실행된 후, 러스트는 self
의 모든 필드들을 재귀적으로 해제하려 시도할 겁니다.
이것은 여러분이 필드들을 해제하기 위해 "소멸자 코드 노가다"를 하지 않아도 되도록 하는 편의 기능입니다. 만약 구조체가 해제될 때 그 필드들을 해제하는 것 외에 다른 특별한 논리가 없다면, Drop
구현이 아예 없어도 된다는 뜻입니다!
러스트 1.0에서는 이것을 막을 안정적인 방법은 존재하지 않습니다.
또한 &mut self
를 취한다는 것은 여러분이 어떻게 재귀적인 해제를 막는다고 해도, self
에서 필드를 이동하는 등의 작업을 러스트가 막는다는 것을 의미합니다. 대부분의 타입에 있어서 이것은 전혀 문제가 없습니다.
예를 들어, Box
의 어떤 구현은 Drop
을 이렇게 작성할 수도 있겠습니다:
#![feature(ptr_internals, allocator_api)] use std::alloc::{Allocator, Global, GlobalAlloc, Layout}; use std::mem; use std::ptr::{drop_in_place, NonNull, Unique}; struct Box<T>{ ptr: Unique<T> } impl<T> Drop for Box<T> { fn drop(&mut self) { unsafe { drop_in_place(self.ptr.as_ptr()); let c: NonNull<T> = self.ptr.into(); Global.deallocate(c.cast(), Layout::new::<T>()) } } } fn main() {}
그리고 이것은 잘 작동하는데, 러스트가 ptr
필드를 해제하려 할 때, 실제 Drop
구현이 없는 Unique를 보기 때문입니다. 마찬가지로 ptr
을 누구도 해제 후 사용할 수 없는데, drop
이 종료하고 나면 접근할 수 없기 때문입니다.
하지만 다음의 코드는 동작하지 않을 겁니다:
#![feature(allocator_api, ptr_internals)] use std::alloc::{Allocator, Global, GlobalAlloc, Layout}; use std::ptr::{drop_in_place, Unique, NonNull}; use std::mem; struct Box<T>{ ptr: Unique<T> } impl<T> Drop for Box<T> { fn drop(&mut self) { unsafe { drop_in_place(self.ptr.as_ptr()); let c: NonNull<T> = self.ptr.into(); Global.deallocate(c.cast(), Layout::new::<T>()); } } } struct SuperBox<T> { my_box: Box<T> } impl<T> Drop for SuperBox<T> { fn drop(&mut self) { unsafe { // 슈-퍼 최적화: `my_box`를 `drop`하지 않고 // 그 내용물을 해제합니다 let c: NonNull<T> = self.my_box.ptr.into(); Global.deallocate(c.cast::<u8>(), Layout::new::<T>()); } } } fn main() {}
SuperBox
의 소멸자에서 my_box
의 ptr
을 해제한 후에, 러스트는 해맑게 my_box
에게 스스로를 해제하라고 말할 것이고, 그럼 해제 후 사용과 이중 해제로 모든 것이 폭발할 겁니다.
이러한 재귀적인 해제 동작은 Drop
을 구현하든 하지 않든 모든 구조체와 열거형에 적용됩니다. 따라서 이러한 구조체는
#![allow(unused)] fn main() { struct Boxy<T> { data1: Box<T>, data2: Box<T>, info: u32, } }
그 자체로 Drop
을 구현하지 않더라도, data1
과 data2
의 필드들이 해제될 "것 같을 때" 그 소멸자를 호출할 것입니다. 우리는 이런 타입이 Drop
이 필요하다고 합니다, 그 자체로는 Drop
이 아니더라도요.
마찬가지로,
#![allow(unused)] fn main() { enum Link { Next(Box<Link>), None, } }
이 열거형은 오직 그 값이 Next
일 때만 그 안의 Box
필드를 해제할 것입니다.
일반적으로는 여러분이 데이터 구조를 바꿀 때마다 drop
을 추가/삭제하지 않아도 되기 때문에 이것은 매우 좋게 동작합니다. 하지만, 여전히 소멸자를 가지고 좀더 복잡한 작업을 할 때의 많은 올바른 사용 사례가 확실히 있습니다.
재귀적 해제를 수동으로 바꿔서 drop
중에 Self
에서 필드를 이동하는 것을 허용하는 고전적인 안전한 방법은 Option
을 사용하는 것입니다:
#![feature(allocator_api, ptr_internals)] use std::alloc::{Allocator, GlobalAlloc, Global, Layout}; use std::ptr::{drop_in_place, Unique, NonNull}; use std::mem; struct Box<T>{ ptr: Unique<T> } impl<T> Drop for Box<T> { fn drop(&mut self) { unsafe { drop_in_place(self.ptr.as_ptr()); let c: NonNull<T> = self.ptr.into(); Global.deallocate(c.cast(), Layout::new::<T>()); } } } struct SuperBox<T> { my_box: Option<Box<T>> } impl<T> Drop for SuperBox<T> { fn drop(&mut self) { unsafe { // 슈-퍼 최적화: `my_box`를 `drop`하지 않고 // 그 내용물을 해제합니다. 러스트가 `my_box`를 `drop`하지 않도록 // 이 필드를 `None`으로 설정해야 합니다. let my_box = self.my_box.take().unwrap(); let c: NonNull<T> = my_box.ptr.into(); Global.deallocate(c.cast(), Layout::new::<T>()); mem::forget(my_box); } } } fn main() {}
하지만 이것은 좀 이상한 의미를 담고 있습니다: 항상 Some
이어야 하는 필드가 None
일 수도 있다는 겁니다 (소멸자에서 이런 일이 일어나기 때문에요).
당연히 역으로는 말이 됩니다: 소멸자에서는 self
에 임의의 메서드를 호출할 수 있고, 이렇게 하는 것이 필드를 비초기화한 후에 그 필드를 건드리지 못하게 합니다.
하지만 이렇게 할 때 그 필드에 다른 임의의 잘못된 상태를 만드는 것은 막지 못합니다.
득과 실을 비교했을 때 이것은 괜찮은 선택입니다. 분명히 기본적으로 해야 하는 방법이죠. 하지만 미래에는 필드가 자동으로 해제되면 안된다고 말하는, 공식적인 방법이 있기를 바랍니다.
누설 (漏泄)
소유권 기반 자원 관리는 합성을 쉽게 하기 위한 것입니다. 객체를 만들 때 자원을 획득하고, 객체가 소멸될 때 자원을 반납합니다. 소멸이 자동으로 처리되므로, 여러분이 자원을 반납하는 것을 까먹는 일이 없다는 뜻이고, 또한 최대한 빨리 반납이 이루어진다는 것입니다! 분명 이건 완벽하고 우리의 모든 문제는 해결된 것입니다.
모든 것은 끔찍하고 우리는 이제 새롭고 괴이한 문제들을 마주해야 합니다.
많은 사람들이 러스트가 자원 누설을 제거한다고 믿고 싶어합니다. 현실적으로, 이것은 맞는 말입니다. 안전한 러스트 프로그램이 통제되지 않는 방법으로 자원을 누설한다면 아마 놀랄 겁니다.
하지만 이론적인 측면에서 보자면 전혀 사실이 아니며, 이것은 어떻게 보든 상관없습니다. 엄밀하게 보자면, "누설"이라는 것은 너무나도 모호해서 방지할 수가 없습니다. 어떤 컬렉션을 프로그램의 시작 때에 초기화하고, 소멸자가 있는 객체들 덩어리로 채우고, 그것을 전혀 사용하지 않는 무한 이벤트 반복문에 들어가는 것은 꽤나 흔한 일입니다. 그 컬렉션은 쓸모 없이 앉아서 시간이나 때우겠죠, 프로그램이 종료될 때까지 귀중한 자원을 쥐고서요 (그 때에는 어차피 운영체제가 그 모든 자원들을 회수할 테니까요).
우리는 좀더 제한된 형태의 누설을 생각할 수 있습니다: 접근할 수 없는 값을 해제하는 데 실패하는 것이죠. 러스트는 이것도 막지 않습니다. 사실 러스트에는 이것을 하는 함수가 있습니다: mem::forget
입니다.
이 함수는 전달된 값을 소비하고 그 소멸자를 실행하지 않습니다.
예전에는 mem::forget
이 사용되지 말라는 뜻에서 unsafe
로 표시되었는데, 소멸자를 호출하는 데 실패하는 것은 일반적으로 좋은 행동은 아니기 때문입니다 (어떤 불안전한 코드에서는 유용해도 말이죠).
하지만 이것은 비논리적인 행동으로 드러났습니다: 안전한 코드에서 소멸자를 호출하는 데 실패하는 방법은 엄청나게 많거든요. 가장 유명한 예제는 서로를 가리키는 Rc<RefCell<T>>
같은 것을 만드는 것입니다.
안전한 코드는 소멸자 누설이 발생하지 않는다고 가정하는 게 합리적인데, 소멸자를 누설하는 프로그램은 아마도 잘못된 것이기 때문입니다. 하지만 불안전한 코드는 안전하기 위해서 소멸자가 실행하는 것에 의지할 수는 없습니다.
대부분의 타입은 이것과 상관이 없습니다: 만약 소멸자를 누설하면 그 타입은 정의에 의해 접근할 수 없게 되고, 따라서 별 문제가 없게 됩니다, 그렇죠?
예를 들어, 만약 Box<u8>
을 누설한다면 메모리를 좀 낭비하기는 하겠지만 메모리 안전성을 침해할 일은 거의 없을 겁니다.
하지만 대리 타입에서는 소멸자 누설을 주의해야 합니다. 이 타입들은 외부의 객체를 접근하는 것을 관리하지만, 그것을 실제로 소유하지는 않는 타입입니다. 대리 타입은 꽤 희귀합니다. 여러분이 신경써야 할 대리 객체들은 더더욱 희귀합니다. 하지만 우리는 표준 라이브러리에 있는 3개의 흥미로운 예제에 집중하겠습니다:
vec::Drain
Rc
thread::scoped::JoinGuard
Drain
drain
은 컨테이너 타입을 소비하지 않고 그 컨테이너에서 데이터를 이동하는 컬렉션 API입니다. 이것을 이용하면 Vec
의 내용물을 모두 회수한 뒤에 그 할당된 메모리를 재사용할 수 있게 되죠.
이것은 Vec
의 내용물을 값으로 반환하는 반복자(Drain
)을 만들어 냅니다.
이제 Drain
을 반복하던 도중을 생각해 봅시다: 어떤 값들은 이동되었고, 나머지는 아닙니다. 이 뜻은 Vec
의 일부분은 이제 논리적으로 미초기화된 데이터로 가득 차 있다는 겁니다!
우리는 값을 제거할 때마다 Vec
의 모든 원소들을 앞으로 당길 수도 있겠지만, 그러면 꽤나 치명적인 성능 저하가 나타날 겁니다.
그 대신, 우리는 Drain
이 해제될 때 Vec
의 할당된 메모리를 고치는 게 좋겠습니다. 이 소멸자는 Drain
자신의 반복을 끝내고, 삭제되지 않은 모든 원소들을 앞으로 당기고, Vec
의 len
을 고칩니다.
이것은 심지어 되감기에도 안전합니다! 쉽군요!
이제 다음의 코드를 생각해 보세요:
let mut vec = vec![Box::new(0); 4];
{
// `drain`을 시작합니다, `vec`은 더 이상 접근할 수 없습니다
let mut drainer = vec.drain(..);
// 2개의 원소들을 꺼내서 바로 해제시킵니다
drainer.next();
drainer.next();
// `drainer`를 없애지만, 소멸자를 호출하지는 않습니다
mem::forget(drainer);
}
// 이런, `vec[0]` 은 해제되었는데, 우리는 해제된 메모리를 가리키는 포인터를 읽고 있습니다!
println!("{}", vec[0]);
이건 꽤나 분명히 좋지 않습니다. 불행하게도, 우리는 진퇴양난의 상황에 빠졌습니다: 모든 단계에서 안정적인 상태를 유지하는 것은 엄청난 비용을 유발합니다 (그리고 API의 모든 장점을 상쇄하겠죠). 안정적인 상태를 유지하지 못하면 안전한 코드에서 미정의 동작이 나올 겁니다 (API가 불건전해지겠죠).
그럼 우리는 어떻게 할까요? 음, 우리는 자명하게 안정적인 상태를 고를 수 있습니다: 반복을 시작할 때 Vec
의 len
을 0으로 만들고, 소멸자에서 필요하다면 len
을 고치는 겁니다.
이 방법이라면, 만약 모든 것이 평소처럼 동작한다면 우리는 최소의 비용으로 원하는 동작을 얻어냅니다. 하지만 만약 누군가가 반복 중간에 mem::forget
을 사용할 대담함이 있다면, 그것이 초래하는 결과는 더한 누설입니다
(그리고 Vec
을 예상 밖이지만 안정적이기는 한 상태로 만듭니다). 우리는 mem::forget
을 안전하다고 받아들였으므로, 이것은 명확하게 안전합니다. 우리는 이렇게 누설이 더한 누설을 부르는 것을 누설 증폭이라고 부릅니다.
Rc
Rc
는 흥미로운 경우인데, 처음 볼 때는 이것이 대리 타입이라고는 전혀 생각되지 않기 때문입니다. 어쨌든 이것은 가리키는 데이터를 관리하고, 모든 그 값의 Rc
를 해제하면 그 값이 해제될 것이기 때문입니다.
Rc
를 누설하는 것이 그렇게 위험할 것 같지는 않은데요. 참조 횟수를 영원히 증가시킨 상태로 방치할 것이고 데이터가 해제되는 것을 막겠지만, 그건 Box
의 경우와 같잖아요, 그렇죠?
땡, 틀렸습니다.
Rc
의 간단한 구현을 생각해 볼까요:
struct Rc<T> {
ptr: *mut RcBox<T>,
}
struct RcBox<T> {
data: T,
ref_count: usize,
}
impl<T> Rc<T> {
fn new(data: T) -> Self {
unsafe {
// heap::allocate가 이렇게 작동한다면 정말 좋지 않을까요?
let ptr = heap::allocate::<RcBox<T>>();
ptr::write(ptr, RcBox {
data,
ref_count: 1,
});
Rc { ptr }
}
}
fn clone(&self) -> Self {
unsafe {
(*self.ptr).ref_count += 1;
}
Rc { ptr: self.ptr }
}
}
impl<T> Drop for Rc<T> {
fn drop(&mut self) {
unsafe {
(*self.ptr).ref_count -= 1;
if (*self.ptr).ref_count == 0 {
// 데이터를 해제합니다
ptr::read(self.ptr);
heap::deallocate(self.ptr);
}
}
}
}
이 코드는 암묵적이고 잘 보이지 않는 가정을 포함하고 있습니다: usize::MAX
보다 많은 양의 Rc
가 메모리에 있을 수 없기 때문에, ref_count
가 usize
안에 들어갈 거라는 겁니다.
하지만 이 자체도 또한 ref_count
가 메모리에 있는 Rc
들의 갯수를 정확히 반영한다는 가정을 하고 있는데, 우리는 mem::forget
의 존재 때문에 이것이 거짓이라는 것을 압니다.
mem::forget
을 사용하면 ref_count
를 오버플로우할 수 있고, 그 다음 Rc
들을 해제하면 ref_count
는 0이 되지만 메모리에는 엄청난 양의 Rc
가 있게 됩니다.
그럼 우리는 행복하게 Rc
안의 데이터를 해제 후 사용하게 됩니다. 젠장, 좋지 않군요.
이 문제는 ref_count
를 검사하고 무언가를 하는 것으로 해결할 수 있습니다. 표준 라이브러리의 방식은 그냥 강제종료하는 것인데, 그 프로그램이 끔찍하게 타락했기 때문입니다.
그리고 맙소사 이건 정말 우스꽝스러운 특수 경우이군요.
thread::scoped::JoinGuard
주의: 이 API는 표준 라이브러리에서 이미 삭제되었습니다. 더 자세한 내용은 issue #24292를 참고하세요. 이 섹션이 여기 남아 있는 이유는 이것이 표준 라이브러리의 일부분이든 아니든, 이 예제가 여전히 중요하다고 여기기 때문입니다.
thread::scoped
API는 모체의 스택에 있는 데이터를 동기화 없이 참조하는 스레드를 만들 수 있도록 설계되었는데, 이것은 공유된 데이터의 수명이 끝나기 전에 스레드가 모체로 돌아가는 것을 보장하기 때문에 가능합니다.
pub fn scoped<'a, F>(f: F) -> JoinGuard<'a>
where F: FnOnce() + Send + 'a
여기서 f
는 스레드가 실행할 함수입니다. F: Send + 'a
라고 하는 것은 'a
만큼 사는 data
를 참조한다는 뜻이고,
그 data
를 가지고 있거나 아니면 data
가 Sync
라는 말입니다 (이 말은 곧 &data
는 Send
라는 말을 함축합니다).
JoinGuard
가 수명을 가지므로, JoinGuard
는 모체에서 빌려온 모든 데이터를 보관합니다. 이 말은 JoinGuard
는 모체의 데이터보다 오래 살 수 없다는 말입니다.
JoinGuard
가 해제가 될 때 모체를 대기시키는데, 빌려온 데이터가 모체의 범위를 벗어나기 전에 스레드가 종료하는 것을 보장합니다.
사용 방법은 이와 같았습니다:
let mut data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
{
let mut guards = vec![];
for x in &mut data {
// 가변 레퍼런스를 함수 안으로 이동시키고, 다른 스레드에서 실행시킵니다.
// 함수의 수명은 그 안에 저장된 가변 레퍼런스 `x`의 수명에 제한됩니다.
// 그 결과로 반환되는 `JoinGuard`는 함수의 수명을 할당받으므로,
// `JoinGuard`도 `x`처럼 `data`를 가변으로 빌립니다.
// 즉 우리는 `JoinGuard`가 해제될 때까지 `data`를 접근할 수 없습니다.
let guard = thread::scoped(move || {
*x *= 2;
});
// 나중을 위해 `JoinGuard`를 저장합니다
guards.push(guard);
}
// 모든 `JoinGuard`는 해제되는데, 스레드들이 실행을 끝마치고 모체에 합류하도록 강제합니다
// (이 스레드, 즉 모체는 다른 스레드들이 종료할 때까지 대기합니다).
// 스레드들이 합류하고 나면, 대여가 만기되고 `data`는 모체에서 다시 접근할 수 있게 됩니다.
}
// `data`는 여기서 변경됩니다.
원칙적으로는 이 코드는 잘 동작합니다! 러스트의 소유권 시스템은 이것의 안전성을 완벽하게 보장합니다! ...안전하려면 소멸자가 꼭 실행되어야 한다는 사실만 빼면요.
let mut data = Box::new(0);
{
let guard = thread::scoped(|| {
// 이것은 최선의 경우 데이터 경합입니다. 최악의 경우, 역시 해제 후 사용이고요.
*data += 1;
});
// `JoinGuard`가 잊혀졌기 때문에, 모체에서 대기되어야 하는 스레드를 자유롭게 풀어버립니다.
mem::forget(guard);
}
// 따라서 `Box`는 여기서 해제되지만 생성된 스레드는 이 `Box`를 접근하려 시도할지도 모릅니다.
이런. 여기서 소멸자의 실행은 API에 있어서 무척 근본이 되었기 때문에, API는 완전히 다른 디자인으로 바뀌어야 했습니다.
되감기
러스트는 단계별로 나눠진 에러 처리 계획이 있습니다:
- 만약 무언가가 합리적으로 없을 수도 있다면
Option
이 사용됩니다. - 만약 무언가가 잘못되었고 합리적으로 다루어질 수 있다면
Result
가 사용됩니다. - 만약 무언가가 잘못되었고 합리적으로 다루어질 수 없다면 스레드가
panic!
합니다. - 만약 어떤 파멸적인 일이 일어난다면 프로그램은 강제 종료합니다.
Option
과 Result
는 대부분의 상황에서 차고 넘치도록 사용되는데, 이것들이 API 사용자의 판단에 따라 panic!
이나 강제 종료로 승격될 수 있기 때문입니다.
panic!
은 스레드가 정상적인 실행을 멈추고 그 스택을 되감게 만드는데, 이 때 마치 모든 함수들이 바로 반환한 것처럼 소멸자들을 호출합니다.
1.0 버전에서 러스트는 panic!
에 대해 두 마음이 있습니다. 옛날 옛적에 러스트는 훨씬 Erlang과 같았죠.
Erlang처럼 러스트는 경량 작업들이 있었고, 작업들은 적합하지 않은 상태가 되었을 때 panic!
과 함께 스스로를 종료하게 설계되었습니다. Java나 C++에서의 예외와 다르게, panic!
은 어느 때라도 처리할 수 없었습니다.
panic!
은 그 작업의 주인만이 처리할 수 있었죠, 물론 주인도 그 예외를 처리하거나 아니면 스스로 panic!
할 수도 있었습니다.
되감기는 이 이야기에서 중요했는데, 만약 어떤 작업의 소멸자가 호출되지 않는다면 메모리와 다른 시스템 자원들이 누설될 것이기 때문입니다. 작업들은 일반적인 실행 중에 종료될 수 있었기 때문에, 러스트는 장기적인 시스템으로써는 매우 성능이 좋지 않았을 것입니다!
러스트가 현재 우리가 아는 러스트가 되어 가면서, 이런 식의 프로그래밍은 점점 추상화가 없어지면서 유행에서 뒤쳐졌습니다. 가벼운 작업들은 무거운 운영체제 스레드의 이름으로 덮여서 없어졌습니다.
하지만 현재 1.0 러스트에서는 여전히, panic!
은 부모 스레드에서만 처리될 수 있습니다. 이 말은 panic!
을 처리하려면 운영체제 스레드 하나를 통째로 사용해야 한다는 겁니다! 이것은 불행하게도 러스트의 무비용 추상화의 철학에 충돌합니다.
catch_unwind
라는 API가 있습니다. 이것은 스레드를 만들지 않고서 panic!
을 처리하게 해 주죠. 하지만, 우리는 이것도 절제해서 사용하기를 권장합니다.
좀더 자세하게 말하자면, 현재 러스트의 되감기 구현은 "되감지 않는" 경우를 위해 매우 최적화되어 있습니다. 프로그램이 되감지 않는다면, 되감기를 준비한 프로그램이라도 실행 시의 비용은 없을 겁니다.
결과적으로, Java 같은 언어보다는 되감기 작업이 비용이 많이 들게 됩니다. 보통의 상황에서는 여러분의 프로그램을 되감도록 만들지 마세요. 이상적으로는, 프로그래밍 오류나 심각한 문제들에만 panic!
해야 합니다.
러스트의 되감기 전략은 다른 언어들의 되감기 작업과 근본적으로 호환될 만큼 특정되지 않았습니다. 따라서 다른 언어에서 러스트로 되감거나, 혹은 러스트에서 다른 언어로 되감는 작업은 미정의 동작입니다.
여러분은 FFI 경계에서의 모든 panic!
들을 완전히 잡아내야 합니다! 그 시점에서 무엇을 할지는 여러분에게 달려 있지만, 무언가는 해야 될 겁니다. 만약 이것을 하지 못한다면, 최선의 경우 여러분의 프로그램은 박살나고 불탈 겁니다.
최악의 경우 여러분의 프로그램은 박살나거나 불타지 않고, 완전히 망가진 상태로 여전히 실행할 겁니다.
예외 안전성
프로그램이 되감기를 주의해서 사용해야 하긴 하지만, panic!
할 수 있는 코드는 많이 있습니다. None
을 unwrap
하거나, 범위 밖으로 인덱스를 접근하거나, 0으로 나누면 프로그램은 panic!
할 겁니다.
디버그 빌드에서는, 모든 수치 연산은 만약 오버플로우된다면 panic!
합니다. 여러분이 어떤 코드가 실행되는지 매우 주의 깊게, 엄격하게 제어하지 못한다면 거의 모든 것들이 되감기를 할 수 있고, 여러분은 그것에 대비해야 합니다.
되감기에 대비하는 것은 넓은 프로그래밍 분야에서 보통 예외 안전성이라고 부릅니다. 러스트에서는 고려해야 하는 예외 안전성에 두 가지 단계가 있습니다:
-
불안전한 코드에서, 우리는 메모리 안전성을 깨지 않는 수준까지 예외에 안전해야 합니다. 이것을 최소 예외 안전성이라 부르겠습니다.
-
안전한 코드에서, 여러분의 프로그램이 올바른 일을 하는 수준까지 예외에 안전한 것이 좋습니다. 이것을 우리는 최대 예외 안전성이라고 하겠습니다.
러스트의 많은 부분에서 그렇듯이, 되감기에 있어서도 불안전한 코드는 나쁜 안전한 코드를 감당할 준비를 해야 합니다. 잠시 불건전한 상태를 만드는 코드는 panic!
이 일어났을 때 그 상태가 사용되지 않도록 유의해야 합니다.
보통 "유의한다"는 것은 이런 상태들이 존재하는 동안에는 panic!
하지 않는 코드만 실행하도록 하거나, panic!
이 발생할 경우 그 상태를 청소하는 안전 장치를 만드는 것입니다.
panic!
이 일어났을 때 항상 일관적인 상태가 아니어도 됩니다. 우리는 다만 그것이 안전한 상태임을 보장해야 합니다.
대부분의 불안전한 코드는 다른 코드에 연결되어 있지 않아서, 예외에 대해 안전하게 만들기 적당히 쉽습니다. 실행되는 모든 코드를 제어하고, 그 대부분은 panic!
할 수 없죠.
하지만 불안전한 코드에서 호출자가 제공한 코드를 반복적으로 실행하며, 일시적으로 미초기화된 데이터의 배열을 가지고 작업하는 것은 희귀한 것은 아닙니다. 이런 코드는 예외 안전성을 고려해서 주의해야 합니다.
Vec::push_all
Vec::push_all
은 어떤 슬라이스만큼 Vec
을 확장하되 특정화 없이 비교적 효율적으로 하기 위한 임시방편입니다. 여기 간단한 구현이 있습니다:
impl<T: Clone> Vec<T> {
fn push_all(&mut self, to_push: &[T]) {
self.reserve(to_push.len());
unsafe {
// 방금 이만큼 할당했기 때문에 오버플로우되지 않을 겁니다
self.set_len(self.len() + to_push.len());
for (i, x) in to_push.iter().enumerate() {
self.ptr().add(i).write(x.clone());
}
}
}
}
우리는 분명히 Vec
이 차지할 공간을 알고 있으므로 반복되는 공간과 len
검사를 피하기 위해 push
를 피해서 지나칩니다. 이 논리는 완전히 올바릅니다, 다만 물 아래 빙산 같은 한 가지 문제가 있죠: 이 코드가 예외에 안전하지 않다는 겁니다!
set_len
, add
그리고 write
는 모두 괜찮습니다. clone
이 바로 우리가 지나쳤던 panic!
시한폭탄입니다.
Clone
은 우리의 제어를 완벽히 벗어나고, panic!
할 자유가 차고 넘칩니다. 만약 panic!
한다면, 우리의 함수는 Vec
의 길이를 너무 크게 설정한 채로 일찍 종료하게 될 겁니다.
만약 이 Vec
을 누군가 보거나 해제하려 한다면, 미초기화된 메모리를 읽게 될 겁니다!
이 경우에 해결책은 꽤나 단순합니다. 만약 우리가 clone
한 값들만 해제되는 것을 보장하길 원한다면, 반복문을 도는 매번 len
을 설정하면 됩니다.
만약 우리가 미초기화된 메모리가 관측되지 않기를 바란다면, 반복문이 끝난 후 len
을 설정하면 됩니다.
BinaryHeap::sift_up
힙에서 원소를 밀어 올리는 것은 Vec
을 확장하는 것보다 조금 더 복잡합니다. 의사 코드는 다음과 같습니다:
bubble_up(heap, index):
while index != 0 && heap[index] < heap[parent(index)]:
heap.swap(index, parent(index))
index = parent(index)
이 의사 코드를 그대로 러스트로 옮긴다면 대체로 괜찮지만, 좀 거슬리게 성능을 방해하는 것이 있습니다: heap[index]
원소가 의미 없이 계속 바뀌는군요. 우리는 대신 이와 같이 할 것입니다:
bubble_up(heap, index):
let elem = heap[index]
while index != 0 && elem < heap[parent(index)]:
heap[index] = heap[parent(index)]
index = parent(index)
heap[index] = elem
이 코드는 이제 각 원소가 최소한으로 복사된다는 것을 보장합니다 (사실 보통은 elem
이 두 번씩 복사되는 것이 필수적입니다). 하지만 이 프로그램은 이제 예외 안전성 문제가 좀 생겼습니다! 모든 순간, 어떤 값의 복사가 두 개씩 존재합니다.
만약 이 함수에서 panic!
한다면 무언가는 두 번 해제될 겁니다. 게다가 불행하게도, 우리는 이 코드의 완전한 제어권을 가지고 있지도 않습니다: 비교 연산은 사용자가 정의하거든요!
Vec
과 달리 여기서는 해결법이 쉽지는 않습니다. 한 가지 방법은 사용자가 정의한 코드와 불안전한 코드를 두 개의 분리된 단계로 나누는 것입니다:
bubble_up(heap, index):
let end_index = index;
while end_index != 0 && heap[end_index] < heap[parent(end_index)]:
end_index = parent(end_index)
let elem = heap[index]
while index != end_index:
heap[index] = heap[parent(index)]
index = parent(index)
heap[index] = elem
만약 사용자가 정의한 코드가 터진다면 더 이상 문제가 아니게 됩니다, 우리는 아직 힙의 상태를 건드리지 않았거든요. 우리가 힙을 가지고 놀기 시작할 때에는 우리가 신뢰하는 데이터와 함수들만 가지고 작업하고, 따라서 panic!
할 염려는 없습니다.
혹시 여러분은 이런 디자인이 맘에 들지 않을 수도 있습니다. 확실히 이건 속임수입니다! 또 우리는 복잡한 힙 순회를 두 번이나 해야 합니다! 좋아요, 힘든 쪽으로 가 봅시다. 진짜로 신뢰할 수 없는 코드와 불안전한 코드를 섞어 보자구요.
만약 러스트에 자바에서처럼 try
와 finally
가 있었다면, 우리는 이렇게 할 수도 있겠지요:
bubble_up(heap, index):
let elem = heap[index]
try:
while index != 0 && elem < heap[parent(index)]:
heap[index] = heap[parent(index)]
index = parent(index)
finally:
heap[index] = elem
기본적인 생각은 간단합니다: 만약 비교 작업이 panic!
한다면, 우리는 그냥 헐렁한 원소를 논리적으로 미초기화된 인덱스에 던지고 탈출합니다.
힙을 쳐다보는 사람들은 조화롭지 않을 수도 있는 힙을 보겠지만, 최소한 이러면 이중 해제는 초래하지 않을 겁니다! 알고리즘이 정상적으로 종료된다면, 이 작업은 어쨌든 우리가 끝내려고 했던 방향과 일치하게 됩니다.
슬프게도, 러스트에는 그런 제어 구조가 없으므로, 우리는 우리만의 제어 구조를 만들어야 됩니다! 이것을 하는 방법은 알고리즘의 상태를 다른 구조체에 저장하고, 그 구조체에 "finally
" 논리를 위한 소멸자를 추가하는 겁니다.
우리가 panic!
하건 하지 않건, 그 소멸자는 실행되고 우리 뒤를 치워 줄 겁니다.
struct Hole<'a, T: 'a> {
data: &'a mut [T],
/// `elt`는 `new`에서부터 `drop`까지 항상 `Some`입니다.
elt: Option<T>,
pos: usize,
}
impl<'a, T> Hole<'a, T> {
fn new(data: &'a mut [T], pos: usize) -> Self {
unsafe {
let elt = ptr::read(&data[pos]);
Hole {
data,
elt: Some(elt),
pos,
}
}
}
fn pos(&self) -> usize { self.pos }
fn removed(&self) -> &T { self.elt.as_ref().unwrap() }
fn get(&self, index: usize) -> &T { &self.data[index] }
unsafe fn move_to(&mut self, index: usize) {
let index_ptr: *const _ = &self.data[index];
let hole_ptr = &mut self.data[self.pos];
ptr::copy_nonoverlapping(index_ptr, hole_ptr, 1);
self.pos = index;
}
}
impl<'a, T> Drop for Hole<'a, T> {
fn drop(&mut self) {
// 구멍을 다시 채웁니다
unsafe {
let pos = self.pos;
ptr::write(&mut self.data[pos], self.elt.take().unwrap());
}
}
}
impl<T: Ord> BinaryHeap<T> {
fn sift_up(&mut self, pos: usize) {
unsafe {
// `pos`에 있는 값을 떼어내고 구멍을 만듭니다.
let mut hole = Hole::new(&mut self.data, pos);
while hole.pos() != 0 {
let parent = parent(hole.pos());
if hole.removed() >= hole.get(parent) { break }
hole.move_to(parent);
}
// 구멍은 무조건 여기서 다시 채워질 겁니다: panic! 하든 하지 않든요!
}
}
}
오염
모든 불안전한 코드가 최소 예외 안전성을 가지도록 보장해야 하지만, 모든 타입들이 최대 예외 안전성을 보장하는 것은 아닙니다. 어떤 타입이 최대 예외 안전성을 보장하더라도, 여러분의 코드가 그 타입에 추가적인 의미를 부여할 수 있습니다.
예를 들어, 정수는 확실히 예외에 안전하지만, 그 자체로는 의미가 없습니다. panic!
하는 코드가 그 정수를 올바르게 갱신하지 못할 수도 있고, 그러면 프로그램의 상태가 모순된 상태가 됩니다.
이런 것은 보통 괜찮은데요, 예외를 마주하는 모든 것은 곧 소멸되기 때문입니다. 예를 들어, 만약 여러분이 어떤 Vec
을 다른 스레드로 보내고 그 스레드가 panic!
한다면, 그 Vec
이 이상한 상태로 있어도 상관 없습니다.
어차피 해제되고 영원히 없어질 거니까요. 하지만 어떤 타입들은 패닉 경계를 통과해서 값들을 들여오는 것에 능합니다.
이런 타입들은 panic!
을 마주할 경우 자신을 오염시키도록 명시적으로 선택할 수 있습니다. 오염은 특별히 무언가를 요구하지는 않습니다. 보통 이것이 의미하는 것은 그냥 더 이상의 정상적인 사용을 방지하는 것입니다.
이것의 가장 주목할 만한 예는 표준 라이브러리의 Mutex
타입입니다. Mutex
는 그 MutexGuard
들 (락을 얻었을 때 Mutex
가 반환하는 것) 중 하나가 panic!
중에 해제될 때, 그 자신을 오염시킵니다.
이 이후의 Mutex
를 잠그려는 어떤 시도도 Err
를 반환하거나 panic!
할 겁니다.
Mutex
가 오염되는 것은 러스트가 보통 신경쓰는 정도의 안정성을 위해서가 아닙니다. 그것은 잠겨 있는 도중 panic!
을 마주한 Mutex
에서 나온 데이터를 멋모르고 사용하는 것에 대한 안전 장치입니다.
이런 Mutex
에 있는 데이터는 아마도 수정되던 도중이었을 것이며, 따라서 모순되거나 불완전한 상태에 있을 수 있기 때문입니다. 코드가 잘 쓰여졌다면 이런 타입을 사용하는 것이 메모리 안전성을 침해할 수 없다는 것이 중요합니다.
결국 최소한은 예외에 안전해야 하니까요!
하지만 Mutex
가, 이를테면, 제대로 배열되지 않은 BinaryHeap
을 포함하고 있다면, 이것을 사용하는 어떤 코드도 코드의 작성자가 의도한 것을 하지는 못할 겁니다. 그렇기 때문에 프로그램은 정상적으로 진행되어서는 안됩니다.
그래도, 만약 그 값을 가지고 무언가를 할 수 있다고 여러분이 확신한다면, Mutex
는 어쨌든 락을 얻을 수 있도록 하는 메서드를 제공합니다. 안전하기 때문이죠. 다만 말이 안 될 수도 있을 뿐이죠.
동시성과 병렬성
러스트는 언어로서 어떻게 동시성이나 병렬성을 처리할지에 대한 의견이 딱히 없습니다. 표준 라이브러리는 운영체제 스레드와 스레드 실행을 막는 시스템 콜들을 제공하는데, 다 있는 것들이기 때문이고, 또한 이것들은 동작이 충분히 안정적이라 여러분이 그것들을 이용해서 비교적으로 모순 없는 추상화를 제공할 수 있습니다. 메세지 전달, 녹색 스레드, 그리고 비동기 API는 모두 충분히 다양해서 그들을 이용해서 만든 어떤 추상화도, 우리가 1.0 버전을 출시하기까지 바라지 않았던, 성능과 편의성 사이의 등가교환을 수반했습니다.
하지만 러스트가 동시성을 구성하는 방식은 여러분만의 동시성 패러다임을 라이브러리로 설계하고 다른 사람들의 코드가 여러분의 코드와 그냥 잘 동작하도록 만들기에 비교적 쉽습니다.
그저 적절한 곳에 올바른 수명과 Send
와 Sync
를 요구하세요, 그러면 여러분은 경합 시작입니다. 아, 경합이 안... 일어나는... 편이... 좋겠네요.
데이터 경합과 경합 조건
안전한 러스트는 다음과 같이 정의된 데이터 경합이 발생하지 않는 것을 보장합니다:
- 2개 이상의 스레드들이 메모리의 한 곳을 동시적으로 접근하고
- 그 중 1개 이상이 쓰기 작업을 하며
- 그 중 1개 이상이 동기화되지 않은
데이터 경합은 미정의 동작을 유발하므로, 안전한 러스트에서는 발생할 수 없습니다. 데이터 경합은 러스트의 소유권 규칙을 통해 대부분 방지됩니다: 가변 레퍼런스를 복제하는 것이 불가능하므로, 데이터 경합을 일으키는 것은 불가능합니다.
내부 가변성은 이 문제를 더 복잡하게 만드는데, 이것이 Send
와 Sync
트레잇이 있는 이유의 대부분을 차지합니다 (이것에 대해서는 다음 섹션에서 더 다룹니다).
하지만 안전한 러스트는 일반적인 경합 조건을 방지하지 않습니다.
이것은 스케줄러를 통제하지 않는 이상 수학적으로 불가능한데, 보통의 운영 체제 환경은 스케줄러를 통제할 수 없습니다. 프로세스 선점을 통제한다면, 일반적인 경합을 해결할 수 있습니다 - 이러한 기법은 RTIC와 같은 프레임워크에서 사용합니다. 하지만, 스케줄링을 실제로 통제하는 것은 매우 희귀한 경우입니다.
이러한 이유 때문에, 러스트에서는 데드락에 걸리거나 올바르지 않은 동기화를 가지고 무언가 말도 안 되는 짓을 하는 것을 "안전하다"고 봅니다: 이것은 일반적인 경합 조건 혹은 자원 경합으로 알려져 있습니다. 당연히 이런 프로그램은 좋지 않지만, 러스트가 모든 논리 오류를 잡을 수는 없기 마련입니다.
어떤 경우에서건, 경합 조건은 러스트 프로그램에서 그 자체만으로는 메모리 안전성을 침해할 수 없습니다. 반드시 어떤 다른 불안전한 코드와 엮여야만 경합 조건은 실제로 메모리 안전성을 해칠 수 있게 됩니다. 예를 들어, 올바른 프로그램은 이와 같습니다:
#![allow(unused)] fn main() { use std::thread; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; let data = vec![1, 2, 3, 4]; // 우리가 실행을 마치고 나서라도 다른 스레드에서 AtomicUsize가 저장되어 있는 메모리를 // 증가시킬 수 있도록 Arc를 사용합니다. Arc가 없으면 러스트는 프로그램 컴파일을 거부할 겁니다, // thread::spawn의 수명 요구사항 때문이죠! let idx = Arc::new(AtomicUsize::new(0)); let other_idx = idx.clone(); // `move`는 other_idx를 값으로 흡수합니다, 이 스레드로 옮기면서 말이죠 thread::spawn(move || { // idx를 변경해도 됩니다, 이 값은 원자값이므로 // 변경해도 데이터 경합을 초래할 수 없습니다. other_idx.fetch_add(10, Ordering::SeqCst); }); // 원자값에서 가져온 값으로 인덱싱합니다. 이것이 안전한 이유는 우리가 원자값 메모리를 단 한 번만 // 읽고, 그 복사값을 Vec의 인덱싱 구현에 넘겨주기 때문입니다. 이 인덱싱은 똑바로 경계가 검사될 // 것이고, 중간에 값이 변경될 가능성은 없습니다. 하지만 우리의 프로그램은 이것이 실행되기 전에 // 우리가 생성한 스레드가 이 값을 증가시켰다면 panic!할 수 있습니다. 이것은 경합 조건인데, 올바른 // 프로그램 실행은 (panic!하는 것은 올바른 경우가 매우 희귀합니다) 스레드 실행의 순서에 좌우되기 // 때문입니다. println!("{}", data[idx.load(Ordering::SeqCst)]); }
이 코드 대신 우리가 경계 검사를 직접 하고, 데이터를 검사받지 않은 값으로 불안전하게 접근하면 데이터 경합이 일어나서 메모리 안정성을 침해할 수 있습니다:
#![allow(unused)] fn main() { use std::thread; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; let data = vec![1, 2, 3, 4]; let idx = Arc::new(AtomicUsize::new(0)); let other_idx = idx.clone(); // `move`는 other_idx를 값으로 흡수합니다, 이 스레드로 옮기면서 말이죠 thread::spawn(move || { // idx를 변경해도 됩니다, 이 값은 원자값이므로 // 변경해도 데이터 경합을 초래할 수 없습니다. other_idx.fetch_add(10, Ordering::SeqCst); }); if idx.load(Ordering::SeqCst) < data.len() { unsafe { // 경계 검사를 한 후에 idx를 올바르지 않게 가져옵니다. idx는 변했을 수도 있습니다. // 이것은 경합 조건이고, *위험한데*, 그 이유는 우리가 `unsafe`한 `get_unchecked`를 // 하기로 결정했기 때문입니다. println!("{}", data.get_unchecked(idx.load(Ordering::SeqCst))); } } }
Send와 Sync
그래도 모든 것이 가변성을 물려받지는 않습니다. 어떤 타입들은 여러분이 메모리의 어떤 위치를 변경하면서도 여러 번 가리킬 수 있게 해 줍니다. 이 타입들이 이런 접근들을 동기화로 관리하지 않는다면 절대로 스레드 경계에서 안전하지 않습니다.
러스트는 이런 특징을 Send
와 Sync
트레잇을 통해 알아챕니다.
- 어떤 타입이 다른 스레드로 보내도 안전하다면
Send
입니다. - 어떤 타입이 스레드 간에 공유해도 안전하다면
Sync
입니다 (T
는 오직&T
가Send
인 경우에만Sync
입니다).
Send
와 Sync
는 러스트의 동시성 이야기에 근본이 됩니다. 그런 면에서, 이들이 잘 동작하도록 하는 상당한 양의 특수 장치들이 있습니다. 첫째로, 그리고 가장 중요한 것은, 이들은 불안전한 트레잇입니다.
이 뜻은 이것들을 구현하는 것이 불안전하다는 의미이며, 다른 불안전한 코드가 이 구현들을 신뢰할 수 있다는 말입니다. 이것들이 표시 트레잇이기 때문에 (이것들은 메서드와 같은 연관된 것들이 없습니다),
이것들이 잘 구현되었다는 것은 단지 구현하면 가져야 하는 본질적인 속성을 가졌다는 뜻입니다. Send
나 Sync
를 잘못 구현하는 것은 미정의 동작을 유발할 수 있습니다.
Send
와 Sync
는 또한 자동으로 파생되는 트레잇들입니다. 이것이 의미하는 것은, 다른 모든 트레잇과 달리, 어떤 타입이 Send
/Sync
타입들로만 이루어져 있다면, 그 타입 또한 Send
/Sync
라는 것입니다.
거의 모든 기본 타입이 Send
이고 Sync
이고, 그 결과로 여러분이 상호작용하게 될 거의 모든 타입들은 Send
이고 Sync
입니다.
대표적인 예외들은 다음과 같습니다:
- 생 포인터들은
Send
도Sync
도 아닙니다 (안전 장치가 없기 때문이죠). UnsafeCell
은Sync
가 아닙니다 (그리고 따라서Cell
과RefCell
도 아닙니다).Rc
는Send
도Sync
도 아닙니다 (참조 횟수가 공유되고, 동기화가 없기 때문입니다).
Rc
와 UnsafeCell
은 매우 근본적으로 스레드 경계에서 안전하지 않습니다: 동기화되지 않는 가변 공유 상태를 가지기 때문입니다. 하지만 생 포인터들은, 엄밀하게 말해서, 정보 차원에서 스레드 경계 불안전하다고 표시된 것에 가깝습니다.
생 포인터로 무언가 쓸모있는 것을 하려면 역참조를 해야 하는데, 이것은 이미 불안전하죠. 그런 점에서, 생 포인터들이 스레드 경계에서 안전하다고 표시되어도 "괜찮다"고 주장할 수 있습니다.
하지만 이것들이 스레드 경계에서 불안전하다고 표시된 것은 그들을 포함하는 타입들이 자동으로 스레드 경계에서 안전하다는 표시를 받는 것을 방지하기 위해서라는 점이 중요합니다.
그런 타입들은 흔하지 않은, 추적되지 않는 소유권을 가지고 있는데, 그 타입의 제작자가 스레드 안전성에 대해 반드시 깊이 고민했다고 보기는 어렵습니다.
Rc
의 경우를 볼 때, 확실히 스레드 경계에서 안전하지 않은 *mut
를 포함하는 타입의 좋은 예가 됩니다.
자동으로 파생되지 않는 타입들은 원할 경우 그냥 구현할 수 있습니다:
#![allow(unused)] fn main() { struct MyBox(*mut u8); unsafe impl Send for MyBox {} unsafe impl Sync for MyBox {} }
어떤 타입이 부적절하게 자동으로 Send
나 Sync
로 파생되는 굉장히 희귀한 경우, Send
와 Sync
의 구현을 해제할 수도 있습니다:
#![allow(unused)] #![feature(negative_impls)] fn main() { // 어떤 동기화 기초 타입을 위한 어떤 마법적인 의미가 있어요! struct SpecialThreadToken(u8); impl !Send for SpecialThreadToken {} impl !Sync for SpecialThreadToken {} }
그 자체로는 올바르지 않게 Send
와 Sync
를 파생받는 것이 불가능하다는 것에 주의하세요. 다른 불안전한 코드에 의해 특별한 의미를 부여받은 타입들만 올바르지 않게 Send
나 Sync
가 됨으로써 문제를 일으킬 가능성이 있습니다.
생 포인터를 사용하는 대부분의 코드는 Send
와 Sync
가 파생되어도 좋을 만큼 충분한 추상화 안에 갇혀야 합니다. 예를 들어 러스트의 표준 컬렉션들은 모두 Send
와 Sync
입니다 (Send
와 Sync
타입을 포함할 때),
비록 할당과 복잡한 소유권은 관리하기 위해 생 포인터를 많이 사용하긴 했어도 말입니다. 마찬가지로, 이 컬렉션의 대부분의 반복자들은 Send
와 Sync
인데, 이들이 컬렉션에 대해 많은 부분 &
나 &mut
와 같이 동작하기 때문입니다.
예제
Box
는 여러 가지 이유 때문에 컴파일러 내부의 특별한 타입으로 구현되어 있지만, 우리는 비슷한 동작을 하는 무언가를 만들어서 언제 Send
와 Sync
를 구현하는 것이 적절한지 예제를 삼을 수 있습니다.
이것을 Carton
이라 부르겠습니다.
우리는 먼저 스택에 할당된 값을 받아서 힙으로 옮기는 코드를 작성하겠습니다.
#![allow(unused)] fn main() { pub mod libc { pub use ::std::os::raw::{c_int, c_void}; #[allow(non_camel_case_types)] pub type size_t = usize; extern "C" { pub fn posix_memalign(memptr: *mut *mut c_void, align: size_t, size: size_t) -> c_int; } } use std::{ mem::{align_of, size_of}, ptr, cmp::max, }; struct Carton<T>(ptr::NonNull<T>); impl<T> Carton<T> { pub fn new(value: T) -> Self { // 힙에 하나의 T를 저장하기에 충분한 메모리를 할당합니다. assert_ne!(size_of::<T>(), 0, "영량 타입은 이 예제에서는 다루지 않습니다"); let mut memptr: *mut T = ptr::null_mut(); unsafe { let ret = libc::posix_memalign( (&mut memptr as *mut *mut T).cast(), max(align_of::<T>(), size_of::<usize>()), size_of::<T>() ); assert_eq!(ret, 0, "할당 실패 혹은 올바르지 않은 정렬선"); }; // NonNull은 그저 포인터가 널이 아니도록 강제하는 구조일 뿐입니다. let ptr = { // 안전성: memptr 은 우리가 레퍼런스에서 생성했고 우리가 독점적으로 가지고 // 있기 때문에 역참조할 수 있습니다. ptr::NonNull::new(memptr) .expect("posix_memalign이 0을 반환했으니 널이 아님이 보장됨") }; // 값을 스택에서 우리가 힙에 할당한 위치로 옮깁니다. unsafe { // 안전성: 널이 아니라면, posix_memalign은 유효하게 쓸 수 있고 // 잘 정렬된 포인터를 반환합니다. ptr.as_ptr().write(value); } Self(ptr) } } }
이건 그렇게 쓸모가 있지는 않군요, 우리의 사용자들이 값을 주고 나면 그 값을 접근할 방법이 없네요. Box
는 Deref
와 DerefMut
를 구현해서 안의 값을 접근할 수 있게 합니다.
우리도 이걸 합시다.
#![allow(unused)] fn main() { use std::ops::{Deref, DerefMut}; struct Carton<T>(std::ptr::NonNull<T>); impl<T> Deref for Carton<T> { type Target = T; fn deref(&self) -> &Self::Target { unsafe { // 안전성: 포인터는 [`Self::new`]의 논리에 의해 정렬되어 있고, 초기화되었으며, // 역참조할 수 있습니다. 우리는 이것을 읽는 사람들이 `Carton`을 빌리기를 요구하고, // 반환값의 수명은 입력의 수명과 동기화됩니다. 이것이 의미하는 것은 반환된 레퍼런스가 // 해제될 때까지 아무도 `Carton`의 내용물을 변경하지 못하도록 대여 검사기가 // 강제할 거라는 겁니다. self.0.as_ref() } } } impl<T> DerefMut for Carton<T> { fn deref_mut(&mut self) -> &mut Self::Target { unsafe { // 안전성: 포인터는 [`Self::new`]의 논리에 의해 정렬되어 있고, 초기화되었으며, // 역참조할 수 있습니다. 우리는 이것을 변경하는 사람들이 `Carton`을 가변으로 빌리기를 요구하고, // 반환값의 수명은 입력의 수명과 동기화됩니다. 이것이 의미하는 것은 반환된 가변 레퍼런스가 // 해제될 때까지 아무도 `Carton`의 내용물을 접근하지 못하도록 대여 검사기가 // 강제할 거라는 겁니다. self.0.as_mut() } } } }
마지막으로, 우리의 Carton
이 Send
인지, 그리고 Sync
인지 생각해 봅시다. 어떤 타입이 가변 상태를 독점적 접근을 강제하지 않고 다른 타입과 공유하는 일이 없으면 안전하게 Send
가 될 수 있습니다.
각 Carton
은 독립된 포인터를 가지고 있으므로, 괜찮은 것 같습니다.
#![allow(unused)] fn main() { struct Carton<T>(std::ptr::NonNull<T>); // 안전성: 우리 말고는 이 생 포인터를 가지고 있지 않으므로, 만약 `T`가 안전하게 다른 스레드로 보낼 수 있다면 // `Carton`도 안전하게 보낼 수 있습니다. unsafe impl<T> Send for Carton<T> where T: Send {} }
Sync
는 어떨까요? Carton
이 Sync
가 되기 위해서 우리는 &Carton
에 저장된 내용물이 읽히거나 변경되는 도중 다른 &Carton
에 있는 그 내용물을 변경할 수 없다는 것을 강제해야 합니다.
그 포인터에 쓰려면 &mut Carton
이 필요하고, 대여 검사기가 그 가변 레퍼런스가 독점적일 것을 강제하므로, Carton
을 Sync
로 만드는 것에 있어서도 건전성 문제는 없습니다.
#![allow(unused)] fn main() { struct Carton<T>(std::ptr::NonNull<T>); // 안전성: 동기화되지 않고 `&Carton<T>`에서 `&T`로 가는 공개적인 방법이 존재하므로 (`Deref` 같은), // `Carton<T>`는 `T`가 `Sync`가 아니라면 `Sync`가 될 수 없습니다. // 역으로 보면, `Carton` 자체는 아무 내부 가변성도 전혀 사용하지 않습니다: 모든 변경은 독점적 레퍼런스(`&mut`)를 // 통해 이루어지죠. 이것이 의미하는 것은 `T`가 `Sync`면 `Carton<T>`가 `Sync`가 되기에 충분하다는 겁니다: unsafe impl<T> Sync for Carton<T> where T: Sync {} }
우리의 타입이 Send
이고 Sync
인지 판별할 때 우리는 보통 포함되어 있는 모든 타입이 Send
이고 Sync
인 것을 강제합니다. 표준 라이브러리 타입처럼 동작하는 수제 타입을 작성할 때 우리는 같은 요구사항을 가지는지 판별할 수 있습니다.
예를 들어, 다음의 코드는 비슷한 Box
가 Send
가 된다면 Carton
이 Send
라고 판별합니다 (이 경우에는 T
가 Send
이면 Box
가 Send
가 되므로, 결국 조건은 "T
가 Send
라면" 이라는 말과 같습니다).
#![allow(unused)] fn main() { struct Carton<T>(std::ptr::NonNull<T>); unsafe impl<T> Send for Carton<T> where Box<T>: Send {} }
지금 당장은 Carton<T>
은 메모리 누수가 있는데, 할당한 메모리를 절대 해제하지 않기 때문입니다. 이것을 고치면 Send
가 되기 위한 새로운 요구사항이 생기게 됩니다:
우리는 다른 스레드에서 할당되어 넘어온 포인터에 free
를 호출할 수 있는지 알아야 합니다. libc::free
의 문서에서 이것이 사실이라는 것을 확인할 수 있습니다.
#![allow(unused)] fn main() { struct Carton<T>(std::ptr::NonNull<T>); mod libc { pub use ::std::os::raw::c_void; extern "C" { pub fn free(p: *mut c_void); } } impl<T> Drop for Carton<T> { fn drop(&mut self) { unsafe { libc::free(self.0.as_ptr().cast()); } } } }
이런 일이 일어나지 않는 좋은 예는 MutexGuard
입니다: 어떻게 이것이 Send
가 아닌지를 유의하세요.
MutexGuard
의 구현은 여러분이 다른 스레드에서 얻은 락을 해제하려 하지 않는다는 것을 확실히 하도록 요구하는 라이브러리를 사용합니다.
만약 MutexGuard
를 다른 스레드로 보낼 수 있다면 소멸자는 여러분이 보낸 그 스레드에서 실행될 것이고, 요구사항은 충족되지 않을 것입니다.
MutexGuard
는 그래도 Sync
일 수 있는데, 여러분이 다른 스레드로 보낼 수 있는 건 &MutexGuard
이고, 레퍼런스를 해제하는 것은 아무 작업도 실행하지 않기 때문입니다.
원자들 (Atomics)
러스트는 원자들의 메모리 모델에 관해서는 꽤나 대놓고 C++20의 것을 그대로 이어받습니다. 그것은 이 모델이 특별히 대단하거나 이해하기 쉬워서가 아닙니다. 오히려 이 모델은 꽤 복잡하고 몇 가지 문제점이 있는 것으로 알려져 있습니다. 그러나 이것은 모두가 원자들을 설계하는 것은 잘 못한다는 사실에 실리적으로 인정하는 것입니다. 정말 최소한, 우리는 C/C++의 메모리 모델 주변에 있는 도구들과 연구들의 혜택을 받을 수 있습니다. (여러분은 이 모델을 "C/C++11" 혹은 그냥 "C11"로 부르는 것을 볼 것입니다. C는 그냥 C++의 메모리 모델을 복사합니다. 또한 C++11은 그 모델의 처음 버전이었지만 그 뒤로 몇 가지 버그 수정을 받았습니다.)
이 책에서 그 모델을 전부 설명하는 것은 별로 희망이 없습니다. 실용적으로 제대로 이해하려면 하나의 책이 통째로 필요할 만큼, 사람을 정신 나가게 만드는 인과관계 그래프들로 정의되어 있거든요. 만약 모든 자질구레한 세부사항을 알고 싶다면, C++ 명세를 보셔야 할 겁니다. 그래도, 우리는 기본적인 것과 러스트 개발자들이 마주하는 몇 가지 문제들을 설명하려 노력할 것입니다.
C++ 메모리 모델은 근본적으로 우리가 원하는 의미와, 컴파일러가 원하는 최적화와, 우리의 하드웨어가 원하는 비일관적인 혼돈 사이에 다리를 놓고자 하는 것입니다. 우리는 그저 프로그램을 짜고 정확히 우리가 시킨 대로 하게 할 것입니다, 다만 빠르게요. 좋을 것 같지 않습니까?
컴파일러 재배치
컴파일러는 근본적으로 모든 종류의 복잡한 변형을 가해서 데이터 의존성을 줄이고 죽은 코드를 제거할 수 있기를 원합니다. 특히, 컴파일러는 사건들의 실제 순서를 과격하게 바꾸거나, 사건들이 아예 일어나지 않도록 할 수도 있습니다! 우리가 만약 이런 코드를 짠다면:
x = 1;
y = 3;
x = 2;
컴파일러는 여러분의 프로그램이 이렇게 되면 가장 좋을 것이라고 결론 내릴 수 있습니다:
x = 2;
y = 3;
이것은 사건들의 순서를 거꾸로 만들고, 하나의 사건을 완전히 제거했습니다. 한 스레드의 관점에서 볼 때 이것은 완벽히 보이지 않습니다: 문장들을 모두 실행한 뒤에는 완전히 동일한 상태에 있으니까요.
하지만 우리의 프로그램이 여러 개의 스레드를 가진다면, 우리는 y
가 할당되기 전에 x
가 1이 되는 것에 의지하고 있었을 수 있습니다. 우리는 컴파일러가 이런 최적화를 해 주었으면 좋겠습니다, 성능을 매우 향상시킬 수 있거든요.
그렇지만, 우리는 또한 우리의 프로그램이 우리가 말한 것을 하도록 하기를 원합니다.
하드웨어 재배치
한편, 비록 컴파일러가 우리가 원하는 것을 완벽히 이해하고 우리의 뜻을 존중해 주더라도, 대신 우리의 하드웨어가 우리를 문제에 빠뜨릴 수도 있습니다. 문제는 CPU 메모리 계층에 있습니다. 여러분의 하드웨어 안 어딘가에는 분명히 전역으로 공유되는 메모리 공간이 있지만, 각 CPU 코어의 입장에서 이것은 너무 멀고 또한 너무나도 느립니다. 각 CPU는 차라리 데이터의 지역 캐시를 각자 가지고 작업하며 캐시에 그 정도 메모리가 없을 때에만 공유 메모리에 이야기하는 고통을 감수할 겁니다.
하긴 그게 캐시가 있는 이유죠, 그렇죠? 만약 캐시에서 읽는 모든 작업이 공유 메모리로 가서 캐시가 변하지 않았는지를 매번 확인해야 한다면, 캐시의 존재 이유가 뭘까요? 최종적으로 벌어지는 일은 한 스레드에서 어떤 순서로 벌어지는 사건들이 다른 스레드에서 같은 순서로 일어나는 것을 하드웨어가 보장하지 않는다는 것입니다. 이것을 보장하려면 우리는 CPU에게 좀 덜 똑똑하게 일을 하라는 특별한 명령들을 제공해야 합니다.
예를 들어, 우리가 컴파일러에게 이런 논리를 작성하게 했다고 합시다:
처음 상태: x = 0, y = 1
스레드 1 스레드 2
y = 3; if x == 1 {
x = 1; y *= 2;
}
이상적으로는 이 프로그램은 2가지의 가능한 최종 상태가 있습니다:
y = 3
: 스레드 1이 끝나기 전에 스레드 2가 상태를 확인했습니다y = 6
: 스레드 1이 끝난 후에 스레드 2가 상태를 확인했습니다
그러나 여기 하드웨어가 가능할 수도 있게 하는 3번째의 상태가 있습니다:
y = 2
: 스레드 2는x = 1
을 봤지만,y = 3
은 보지 못하고y = 3
을 덮어썼습니다
다른 종류의 CPU는 다른 보장들을 제공한다는 것은 한번 짚고 넘어가겠습니다. 하드웨어를 두 개의 종류로 구분하는 것은 흔한 일입니다: 강하게 정렬된 것과 약하게 정렬된 것들이죠. 가장 유명하기로는 x86/64 는 강하게 순서를 보장하지만, ARM은 순서의 보장이 약합니다. 이것은 동시 프로그래밍에 있어 두 가지 결과를 가져옵니다:
- 강하게 정렬된 하드웨어에서 더 강한 보장을 요구하는 것은 비용이 적거나 심지어 없습니다, 이미 무조건적으로 강한 보장을 하기 때문이죠. 약하게 정렬된 하드웨어에서 약한 보장을 요구하면 성능상의 이득만 주어질 겁니다.
- 강하게 정렬된 하드웨어에서 너무 약한 보장을 요구하면, 여러분의 프로그램이 엄밀하게는 틀렸더라도, 잘 작동하게 될 겁니다. 가능하다면 동시적인 알고리즘은 약하게 정렬된 하드웨어에서 테스트해야 합니다.
데이터 접근
C++ 메모리 모델은 우리의 프로그램에 인과 관계에 대해 이야기할 수 있게 해 줌으로써 이러한 차이를 줄이고자 시도합니다. 일반적으로 이것은 프로그램의 각 부분들과 그것을 실행하는 스레드들 간에 선후 관계를 맺음으로써 이루어집니다. 이것은 엄밀한 선후 관계가 맺어지지 않은 부분에는 하드웨어와 컴파일러가 프로그램을 더 공격적으로 최적화할 수 있도록 하지만, 엄밀하게 선후 관계가 맺어진 곳에서는 좀더 주의하도록 강제합니다. 우리가 이 관계들을 상호작용하는 방법은 데이터 접근과 원자적 접근을 통해서입니다.
데이터 접근은 프로그래밍 세계의 기본 도구입니다. 이것은 기본적으로 동기화되지 않고, 컴파일러들은 이들을 공격적으로 최적화할 수 있습니다. 좀더 자세하게 말하자면, 프로그램이 한 스레드만 사용한다는 가정 하에 데이터 접근은 컴파일러에 의해 순서가 재배치될 수 있습니다. 하드웨어 또한 데이터 접근에서 일어난 변화를 다른 스레드들로 전파하는 것을 원하는 만큼 게으르고 일관성 없게 할 수 있습니다. 가장 중요하게도, 데이터 접근은 데이터 경합이 일어나는 방식입니다. 데이터 접근은 하드웨어와 컴파일러에 매우 우호적이지만, 우리가 봤듯이 이것을 가지고 동기화된 코드를 짜려고 할 때 끔찍한 의미를 제공합니다.
데이터 접근만을 가지고 올바른 동기화 코드를 작성하기는 말 그대로 불가능합니다.
원자적 접근은 우리가 하드웨어와 컴파일러에게 우리의 프로그램이 여러 개의 스레드를 가진다고 말하는 방법입니다. 각각의 원자적 접근은 다른 접근들과 어떤 관계를 형성하는지를 특정하는 순서로 표시될 수 있습니다. 실제로는 이것은 컴파일러와 하드웨어에게 할 수 없는 몇 가지를 말해주는 것입니다. 컴파일러에게는 이것이 명령들의 재배치에 관한 것이 대부분이고, 하드웨어에게는 쓰기 작업이 다른 스레드들로 전파되는 것에 관한 것입니다. 러스트가 제공하는 순서들은 다음과 같습니다:
- 순서적 일관 (SeqCst)
- 방출
- 획득
- 관대
(주의: 우리는 C++의 consume 순서를 의도적으로 제공하지 않습니다)
TODO: negative reasoning vs positive reasoning? TODO: "can't forget to synchronize"
순서적 일관 (Sequentially Consistent)
순서적 일관은 가장 강력한데, 모든 다른 순서들을 제한합니다. 직관적으로, 순서적으로 일관된 작업은 재배치될 수 없습니다: 한 스레드에서 SeqCst
전과 후에 일어나는 접근들은 이전과 이후에 그대로 있습니다.
순서적으로 일관된 원자들과 데이터 접근들만 사용하는, 데이터 경합이 없는 프로그램은 프로그램의 명령들에 있어서 모든 스레드가 동의하는 하나의 전역적인 실행이 있다는 것입니다.
이 실행은 또한 이해하기에 특별히 좋습니다: 그냥 각 스레드의 독립적인 실행들의 조합일 뿐이니까요. 만약 여러분이 좀더 약한 원자적 순서 배치를 사용한다면 이것은 성립하지 않습니다.
순서적 일관성이 개발자에게 비교적 친근하게 다가오는 것은 아무 대가가 없는 것이 아닙니다. 강하게 정렬된 플랫폼에서조차 순서적 일관성은 메모리 경계를 치게 만듭니다.
실제로는 순서적 일관성은 프로그램이 올바르게 되기 위해서는 거의 필요하지 않습니다. 하지만 다른 메모리 순서에 대해 자신이 없다면 순서적 일관성은 확실히 맞는 선택입니다.
필요한 것보다 조금 느리게 여러분의 프로그램이 돌아가는 것이 잘못 돌아가는 것보다는 당연히 낫습니다! 또한 이러한 원자적 접근을 나중에 약한 일관성으로 낮추는 것도 흔한 일입니다. 그냥 SeqCst
를 Relaxed
로 바꾸면 되니까요!
당연하게도, 그러한 변형이 올바른지 증명하는 것은 완전히 다른 문제입니다.
획득-방출 (Acquire-Release)
획득과 방출은 짝을 짓게 의도되었습니다. 이들의 이름이 사용처에 대한 힌트를 주죠: 이들은 락의 획득과 방출, 그리고 임계 영역(Critical Section)이 겹치지 않는 것을 보장하는 데에 완벽하게 걸맞습니다.
직관적으로, 획득 접근은 이 다음의 모든 접근이 이 뒤에 계속 있는 것을 보장합니다. 하지만 획득 전에 발생하는 작업들은 획득 후에 발생하도록 재배치될 수 있습니다. 비슷하게, 방출 접근은 이 전의 모든 접근이 이전에 계속 있게 합니다. 하지만 방출 뒤에 일어나는 작업들은 장출 전에 일어나도록 재배치되어도 상관 없습니다.
스레드 A가 메모리의 한 위치를 방출하고 스레드 B가 이어서 그 똑같은 위치를 획득한다면, 인과관계가 성립합니다. A의 방출 전에 일어난 모든 쓰기 작업은 (비원자적인 쓰기 작업과 관대한 원자적 쓰기 작업을 포함해서) B의 획득 이후 관측될 것입니다. 하지만 다른 스레드들과는 어떤 인과관계도 성립하지 않습니다. 마찬가지로, 만약 A와 B가 메모리의 다른 위치에 접근한다면 그 어떤 인과관계도 성립하지 않습니다.
따라서 방출-획득의 기본 사용법은 간단합니다: 임계 영역을 시작하기 위해 메모리의 어떤 위치를 획득하고, 그 다음 임계 영역을 끝내기 위해 그 위치를 방출하는 것입니다. 예를 들어, 간단한 스핀락(Spinlock)은 이렇게 될 겁니다:
use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; fn main() { let lock = Arc::new(AtomicBool::new(false)); // 이 값은 현재 락이 걸렸는지 여부입니다 // ... 어떻게 락을 스레드들에 배포합니다 ... // 이 변수를 true로 설정함으로써 락을 획득하려 시도합니다 while lock.compare_and_swap(false, true, Ordering::Acquire) { } // 반복문에서 나왔으니 성공적으로 락을 획득했습니다! // ... 무서운 데이터 접근들 ... // 끝났습니다, 락을 방출합니다 lock.store(false, Ordering::Release); }
강하게 정렬된 플랫폼에서 대부분의 접근은 방출이나 획득의 의미를 지니는데, 따라서 방출과 획득 작업의 비용이 거의 없습니다. 약하게 정렬된 플랫폼에서는 이렇지가 않습니다.
관대 (Relaxed)
관대한 접근은 절대적으로 최약입니다. 이들은 자유롭게 재배치될 수 있고, 그 어떤 선후관계도 제공하지 않습니다. 하지만, 관대한 작업들은 원자적입니다.
이 말은, 이들이 데이터 접근으로 취급되지 않고, 이들에 대한 어떤 읽기-수정-쓰기 작업들은 원자적으로 일어난다는 뜻입니다. 관대한 작업들은 여러분이 반드시 일어나게 하고는 싶지만, 그 외에는 특별히 신경쓰지는 않는 것들에 적합합니다.
예를 들어, 만약 어떤 카운터를 다른 접근들에 동기화하는 목적으로 사용하는 것이 아니라면, 그 카운터를 증가시키는 것은 관대한 fetch_add
를 사용해서 여러 스레드에서 안전하게 실행될 겁니다.
강하게 정렬된 플랫폼에서 작업을 관대하게 만드는 것은 별 이득이 없는데, 이 플랫폼은 어차피 보통은 방출-획득의 의미를 제공하기 때문입니다. 하지만 관대한 작업들은 약하게 정렬된 플랫폼에서 더 비용이 적을 수 있습니다.
실습: Vec
구현하기
모든 것을 아우르기 위해, 우리는 처음부터 std::Vec
을 작성할 겁니다. 우리는 안정적인 러스트 버전에서만 작업할 것입니다. 특히 우리는 우리의 코드를 조금 더 좋거나 효율적으로 만들 수 있는 컴파일러 내장 함수들은 어떤 것도 사용하지 않을 건데, 컴파일러 내장 함수들은 항상 불안정하기 때문입니다. 비록 다른 곳들에서는 많은 내장 함수들이 표준화되기도 하지만 말이죠 (std::ptr
과 std::mem
은 많은 컴파일러 내장 함수로 이루어져 있습니다).
궁극적으로 이것이 의미하는 것은 우리의 구현이 가능한 모든 최적화의 혜택을 받지 않을 수도 있다는 말이지만, 순진하게 구현하겠다는 말은 전혀 아닙니다. 우리는 당연히 껄끄러운 세부 사항들을 살펴 보고 우리의 손을 더럽힐 겁니다, 그 문제들이 별로 그럴 만큼의 가치가 없을 때에도요.
고차원을 원하셨으니, 고차원으로 가겠습니다.
구획도 (區劃圖)
먼저, 우리는 구조체의 구획도를 구상해야 합니다. Vec
은 세 개의 부분이 있습니다: 할당된 메모리를 가리키는 포인터, 할당된 메모리의 크기, 그리고 초기화된 원소들의 갯수입니다.
순진하게 보자면, 이 말은 우리가 그냥 이런 디자인을 원한다는 겁니다:
pub struct Vec<T> {
ptr: *mut T,
cap: usize,
len: usize,
}
그리고 이건 실제로 컴파일될 겁니다. 불행하게도, 이런 디자인은 너무 엄격할 겁니다. 컴파일러는 우리에게 너무 엄격한 변성을 줄 겁니다. 따라서 &Vec<&'a str>
을 넣어야 하는 곳에 &Vec<&'static str>
을 사용할 수는 없을 겁니다.
변성에 관한 자세한 부분은 소유권과 수명에 관한 챕터를 보세요.
우리가 소유권 챕터에서 보았듯이, 자신이 소유하는 메모리를 가리키는 생 포인터를 가질 때 표준 라이브러리는 *mut T
대신 Unique<T>
를 사용합니다. Unique
는 불안정하므로, 우리는 설사 가능하더라도 이것을 사용하지 않을 겁니다.
다시 정리하면, Unique
는 생 포인터를 감싸면서 다음의 특성들을 추가합니다:
T
에 대해서 공변하고T
타입의 값을 소유할 수도 있고 (이것은 우리의 예제와는 관련이 없지만, 실제std::vec::Vec<T>
가 이것을 필요로 하는 이유는PhantomData
에 관한 챕터를 보세요)T
가Send
/Sync
하다면 역시Send
/Sync
하고- 이 포인터는 절대 널이 아닙니다 (따라서
Option<Vec<T>>
는 널 포인터 최적화가 됩니다)
우리는 위의 모든 요구사항들을 안정적인 러스트 버전에서 구현할 수 있습니다. 이것을 하기 위해, 우리는 Unique<T>
대신 NonNull<T>
를 사용할 겁니다. 이것은 생 포인터를 감싸는 또다른 구조체인데,
위의 것들 중 두 가지 특성, 즉 T
에 대해 공변하는 것과 절대 널이 아니라는 특성을 가집니다. T
가 Send
/Sync
일 때 역시 Send
/Sync
하도록 구현하면, 우리는 Unique<T>
를 사용하는 것과 같은 결과를 얻게 됩니다:
use std::ptr::NonNull; pub struct Vec<T> { ptr: NonNull<T>, cap: usize, len: usize, } unsafe impl<T: Send> Send for Vec<T> {} unsafe impl<T: Sync> Sync for Vec<T> {} fn main() {}
메모리 할당하기
NonNull
을 사용하는 것은 Vec
(과 모든 표준 컬렉션들)의 중요한 동작에 걸림돌이 됩니다: 비어있는 Vec
을 생성하면 실제로는 아무것도 할당하지 않아야 한다는 것입니다. 이것은 크기가 0인 메모리를 할당하는 것과는 다릅니다, 전역 할당자에게는 크기가 0인 메모리를 할당하는 것은 용납되지 않거든요 (이렇게 하면 미정의 동작이 일어납니다!). 따라서 우리가 할당할 수도 없고, ptr
에 널 포인터를 넣을 수도 없다면, Vec::new
에서는 어떻게 하죠? 음, 그냥 아무 쓰레기 값이나 집어넣죠!
할당이 없을 때 cap == 0
을 먼저 확인하도록 할 것이기 때문에 이렇게 해도 전혀 문제가 없습니다. 심지어 우리는 이 경우를 특별히 처리할 필요도 거의 없는데, 어차피 보통 우리는 cap > len
이나 len > 0
을 확인해야 하기 때문입니다. 여기에 들어가기에 추천되는 러스트 값은 mem::align_of::<T>()
입니다. NonNull
에서는 이것을 위한 편리 함수를 제공합니다: NonNull::dangling()
입니다. 우리는 꽤 많은 곳에서 dangling
을 사용할 텐데, 실제 할당은 없지만 null
은 컴파일러가 나쁜 짓을 하도록 만들 것이기 때문입니다.
그래서:
use std::mem;
impl<T> Vec<T> {
pub fn new() -> Self {
assert!(mem::size_of::<T>() != 0, "아직 영량 타입을 처리할 준비가 되지 않았습니다!");
Vec {
ptr: NonNull::dangling(),
len: 0,
cap: 0,
}
}
}
fn main() {}
저 코드에 assert
문이 들어가 있는데, 영량 타입은 우리의 코드에서 전반적으로 특별한 처리가 필요하고, 그 문제를 지금은 보류하고 싶기 때문입니다. 이 assert
문이 없으면, 우리의 초기 코드는 매우 나쁜 짓을 할 겁니다.
다음으로 우리는 실제로 공간을 원할 때 무엇을 해야 할지를 고민해야 합니다. 이것을 위해서 우리는 안정적인 러스트의 std::alloc
에 있는 전역 할당 함수 alloc
, realloc
,
그리고 dealloc
을 사용합니다. 이 함수들은 나중에 std::alloc::Global
타입이 표준화되고 나면 구식으로 취급될 겁니다.
우리는 또한 메모리 소진 상황(out-of-memory, OOM)도 처리해야 합니다. 표준 라이브러리는 alloc::handle_alloc_error
함수를 제공하는데, 이 함수는 플랫폼마다 다른 방식으로 프로그램을 강제 종료합니다. 우리가 panic!
하지 않고 강제 종료하는 이유는 되감기 작업에서 할당이 일어날 수 있는데, 할당자가 "앗, 메모리가 더 이상 없습니다"라고 말하며 돌아왔는데 할당이 일어나는 것은 나쁜 일 같기 때문입니다.
물론, 대부분의 플랫폼은 일반적인 상황에서는 메모리가 소진되는 일이 없기 때문에, 이렇게 처리하는 것은 좀 바보같아 보입니다. 여러분이 실제로 모든 메모리를 다 사용하기 시작할 때 여러분의 운영 체제는 아마 다른 수단을 동원해서 그 프로그램을 종료시킬 겁니다. 우리가 OOM을 초래할 가장 가능성 있는 방법은 터무니없을 정도로 많은 양의 메모리를 한번에 요청하는 것입니다 (예: 이론적인 주소 공간의 절반). 따라서 아마도 panic!
해도 괜찮고, 나쁜 일은 일어나지 않을 겁니다. 그래도 우리는 최대한 표준 라이브러리와 같이 되도록 노력하는 것이기 때문에, 우리는 그냥 전체 프로그램을 강제 종료할 겁니다.
좋습니다, 이제 우리는 성장하는 논리를 작성할 수 있겠군요, 대략적으로 우리는 이런 논리를 원합니다:
if cap == 0:
allocate()
cap = 1
else:
reallocate()
cap *= 2
하지만 러스트에서 유일하게 지원되는 할당자 API는 너무 저수준이라 우리가 좀더 작업을 해야 합니다. 우리는 또한 매우 큰 할당이나 빈 할당으로 인해 일어날 수 있는 특수한 상황들을 방어해야 합니다.
In particular, ptr::offset
will cause us a lot of trouble, because it has
the semantics of LLVM's GEP inbounds instruction. If you're fortunate enough to
not have dealt with this instruction, here's the basic story with GEP: alias
analysis, alias analysis, alias analysis. It's super important to an optimizing
compiler to be able to reason about data dependencies and aliasing.
As a simple example, consider the following fragment of code:
*x *= 7;
*y *= 3;
If the compiler can prove that x
and y
point to different locations in
memory, the two operations can in theory be executed in parallel (by e.g.
loading them into different registers and working on them independently).
However the compiler can't do this in general because if x and y point to
the same location in memory, the operations need to be done to the same value,
and they can't just be merged afterwards.
When you use GEP inbounds, you are specifically telling LLVM that the offsets you're about to do are within the bounds of a single "allocated" entity. The ultimate payoff being that LLVM can assume that if two pointers are known to point to two disjoint objects, all the offsets of those pointers are also known to not alias (because you won't just end up in some random place in memory). LLVM is heavily optimized to work with GEP offsets, and inbounds offsets are the best of all, so it's important that we use them as much as possible.
So that's what GEP's about, how can it cause us trouble?
The first problem is that we index into arrays with unsigned integers, but
GEP (and as a consequence ptr::offset
) takes a signed integer. This means
that half of the seemingly valid indices into an array will overflow GEP and
actually go in the wrong direction! As such we must limit all allocations to
isize::MAX
elements. This actually means we only need to worry about
byte-sized objects, because e.g. > isize::MAX
u16
s will truly exhaust all of
the system's memory. However in order to avoid subtle corner cases where someone
reinterprets some array of < isize::MAX
objects as bytes, std limits all
allocations to isize::MAX
bytes.
On all 64-bit targets that Rust currently supports we're artificially limited
to significantly less than all 64 bits of the address space (modern x64
platforms only expose 48-bit addressing), so we can rely on just running out of
memory first. However on 32-bit targets, particularly those with extensions to
use more of the address space (PAE x86 or x32), it's theoretically possible to
successfully allocate more than isize::MAX
bytes of memory.
However since this is a tutorial, we're not going to be particularly optimal
here, and just unconditionally check, rather than use clever platform-specific
cfg
s.
The other corner-case we need to worry about is empty allocations. There will
be two kinds of empty allocations we need to worry about: cap = 0
for all T,
and cap > 0
for zero-sized types.
These cases are tricky because they come down to what LLVM means by "allocated". LLVM's notion of an allocation is significantly more abstract than how we usually use it. Because LLVM needs to work with different languages' semantics and custom allocators, it can't really intimately understand allocation. Instead, the main idea behind allocation is "doesn't overlap with other stuff". That is, heap allocations, stack allocations, and globals don't randomly overlap. Yep, it's about alias analysis. As such, Rust can technically play a bit fast and loose with the notion of an allocation as long as it's consistent.
Getting back to the empty allocation case, there are a couple of places where
we want to offset by 0 as a consequence of generic code. The question is then:
is it consistent to do so? For zero-sized types, we have concluded that it is
indeed consistent to do a GEP inbounds offset by an arbitrary number of
elements. This is a runtime no-op because every element takes up no space,
and it's fine to pretend that there's infinite zero-sized types allocated
at 0x01
. No allocator will ever allocate that address, because they won't
allocate 0x00
and they generally allocate to some minimal alignment higher
than a byte. Also generally the whole first page of memory is
protected from being allocated anyway (a whole 4k, on many platforms).
However what about for positive-sized types? That one's a bit trickier. In principle, you can argue that offsetting by 0 gives LLVM no information: either there's an element before the address or after it, but it can't know which. However we've chosen to conservatively assume that it may do bad things. As such we will guard against this case explicitly.
Phew
Ok with all the nonsense out of the way, let's actually allocate some memory:
use std::alloc::{self, Layout};
impl<T> Vec<T> {
fn grow(&mut self) {
let (new_cap, new_layout) = if self.cap == 0 {
(1, Layout::array::<T>(1).unwrap())
} else {
// This can't overflow since self.cap <= isize::MAX.
let new_cap = 2 * self.cap;
// `Layout::array` checks that the number of bytes is <= usize::MAX,
// but this is redundant since old_layout.size() <= isize::MAX,
// so the `unwrap` should never fail.
let new_layout = Layout::array::<T>(new_cap).unwrap();
(new_cap, new_layout)
};
// Ensure that the new allocation doesn't exceed `isize::MAX` bytes.
assert!(new_layout.size() <= isize::MAX as usize, "Allocation too large");
let new_ptr = if self.cap == 0 {
unsafe { alloc::alloc(new_layout) }
} else {
let old_layout = Layout::array::<T>(self.cap).unwrap();
let old_ptr = self.ptr.as_ptr() as *mut u8;
unsafe { alloc::realloc(old_ptr, old_layout, new_layout.size()) }
};
// If allocation fails, `new_ptr` will be null, in which case we abort.
self.ptr = match NonNull::new(new_ptr as *mut T) {
Some(p) => p,
None => alloc::handle_alloc_error(new_layout),
};
self.cap = new_cap;
}
}
fn main() {}
Push and Pop
Alright. We can initialize. We can allocate. Let's actually implement some
functionality! Let's start with push
. All it needs to do is check if we're
full to grow, unconditionally write to the next index, and then increment our
length.
To do the write we have to be careful not to evaluate the memory we want to write
to. At worst, it's truly uninitialized memory from the allocator. At best it's the
bits of some old value we popped off. Either way, we can't just index to the memory
and dereference it, because that will evaluate the memory as a valid instance of
T. Worse, foo[idx] = x
will try to call drop
on the old value of foo[idx]
!
The correct way to do this is with ptr::write
, which just blindly overwrites the
target address with the bits of the value we provide. No evaluation involved.
For push
, if the old len (before push was called) is 0, then we want to write
to the 0th index. So we should offset by the old len.
pub fn push(&mut self, elem: T) {
if self.len == self.cap { self.grow(); }
unsafe {
ptr::write(self.ptr.as_ptr().add(self.len), elem);
}
// Can't fail, we'll OOM first.
self.len += 1;
}
Easy! How about pop
? Although this time the index we want to access is
initialized, Rust won't just let us dereference the location of memory to move
the value out, because that would leave the memory uninitialized! For this we
need ptr::read
, which just copies out the bits from the target address and
interprets it as a value of type T. This will leave the memory at this address
logically uninitialized, even though there is in fact a perfectly good instance
of T there.
For pop
, if the old len is 1, for example, we want to read out of the 0th
index. So we should offset by the new len.
pub fn pop(&mut self) -> Option<T> {
if self.len == 0 {
None
} else {
self.len -= 1;
unsafe {
Some(ptr::read(self.ptr.as_ptr().add(self.len)))
}
}
}
Deallocating
Next we should implement Drop so that we don't massively leak tons of resources.
The easiest way is to just call pop
until it yields None, and then deallocate
our buffer. Note that calling pop
is unneeded if T: !Drop
. In theory we can
ask Rust if T
needs_drop
and omit the calls to pop
. However in practice
LLVM is really good at removing simple side-effect free code like this, so I
wouldn't bother unless you notice it's not being stripped (in this case it is).
We must not call alloc::dealloc
when self.cap == 0
, as in this case we
haven't actually allocated any memory.
impl<T> Drop for Vec<T> {
fn drop(&mut self) {
if self.cap != 0 {
while let Some(_) = self.pop() { }
let layout = Layout::array::<T>(self.cap).unwrap();
unsafe {
alloc::dealloc(self.ptr.as_ptr() as *mut u8, layout);
}
}
}
}
Deref
Alright! We've got a decent minimal stack implemented. We can push, we can
pop, and we can clean up after ourselves. However there's a whole mess of
functionality we'd reasonably want. In particular, we have a proper array, but
none of the slice functionality. That's actually pretty easy to solve: we can
implement Deref<Target=[T]>
. This will magically make our Vec coerce to, and
behave like, a slice in all sorts of conditions.
All we need is slice::from_raw_parts
. It will correctly handle empty slices
for us. Later once we set up zero-sized type support it will also Just Work
for those too.
use std::ops::Deref;
impl<T> Deref for Vec<T> {
type Target = [T];
fn deref(&self) -> &[T] {
unsafe {
std::slice::from_raw_parts(self.ptr.as_ptr(), self.len)
}
}
}
And let's do DerefMut too:
use std::ops::DerefMut;
impl<T> DerefMut for Vec<T> {
fn deref_mut(&mut self) -> &mut [T] {
unsafe {
std::slice::from_raw_parts_mut(self.ptr.as_ptr(), self.len)
}
}
}
Now we have len
, first
, last
, indexing, slicing, sorting, iter
,
iter_mut
, and all other sorts of bells and whistles provided by slice. Sweet!
Insert and Remove
Something not provided by slice is insert
and remove
, so let's do those
next.
Insert needs to shift all the elements at the target index to the right by one.
To do this we need to use ptr::copy
, which is our version of C's memmove
.
This copies some chunk of memory from one location to another, correctly
handling the case where the source and destination overlap (which will
definitely happen here).
If we insert at index i
, we want to shift the [i .. len]
to [i+1 .. len+1]
using the old len.
pub fn insert(&mut self, index: usize, elem: T) {
// Note: `<=` because it's valid to insert after everything
// which would be equivalent to push.
assert!(index <= self.len, "index out of bounds");
if self.len == self.cap { self.grow(); }
unsafe {
// ptr::copy(src, dest, len): "copy from src to dest len elems"
ptr::copy(
self.ptr.as_ptr().add(index),
self.ptr.as_ptr().add(index + 1),
self.len - index,
);
ptr::write(self.ptr.as_ptr().add(index), elem);
}
self.len += 1;
}
Remove behaves in the opposite manner. We need to shift all the elements from
[i+1 .. len + 1]
to [i .. len]
using the new len.
pub fn remove(&mut self, index: usize) -> T {
// Note: `<` because it's *not* valid to remove after everything
assert!(index < self.len, "index out of bounds");
unsafe {
self.len -= 1;
let result = ptr::read(self.ptr.as_ptr().add(index));
ptr::copy(
self.ptr.as_ptr().add(index + 1),
self.ptr.as_ptr().add(index),
self.len - index,
);
result
}
}
IntoIter
Let's move on to writing iterators. iter
and iter_mut
have already been
written for us thanks to The Magic of Deref. However there's two interesting
iterators that Vec provides that slices can't: into_iter
and drain
.
IntoIter consumes the Vec by-value, and can consequently yield its elements by-value. In order to enable this, IntoIter needs to take control of Vec's allocation.
IntoIter needs to be DoubleEnded as well, to enable reading from both ends.
Reading from the back could just be implemented as calling pop
, but reading
from the front is harder. We could call remove(0)
but that would be insanely
expensive. Instead we're going to just use ptr::read to copy values out of
either end of the Vec without mutating the buffer at all.
To do this we're going to use a very common C idiom for array iteration. We'll make two pointers; one that points to the start of the array, and one that points to one-element past the end. When we want an element from one end, we'll read out the value pointed to at that end and move the pointer over by one. When the two pointers are equal, we know we're done.
Note that the order of read and offset are reversed for next
and next_back
For next_back
the pointer is always after the element it wants to read next,
while for next
the pointer is always at the element it wants to read next.
To see why this is, consider the case where every element but one has been
yielded.
The array looks like this:
S E
[X, X, X, O, X, X, X]
If E pointed directly at the element it wanted to yield next, it would be indistinguishable from the case where there are no more elements to yield.
Although we don't actually care about it during iteration, we also need to hold onto the Vec's allocation information in order to free it once IntoIter is dropped.
So we're going to use the following struct:
pub struct IntoIter<T> {
buf: NonNull<T>,
cap: usize,
start: *const T,
end: *const T,
}
And this is what we end up with for initialization:
impl<T> IntoIterator for Vec<T> {
type Item = T;
type IntoIter = IntoIter<T>;
fn into_iter(self) -> IntoIter<T> {
// Make sure not to drop Vec since that would free the buffer
let vec = ManuallyDrop::new(self);
// Can't destructure Vec since it's Drop
let ptr = vec.ptr;
let cap = vec.cap;
let len = vec.len;
IntoIter {
buf: ptr,
cap,
start: ptr.as_ptr(),
end: if cap == 0 {
// can't offset off this pointer, it's not allocated!
ptr.as_ptr()
} else {
unsafe { ptr.as_ptr().add(len) }
},
}
}
}
Here's iterating forward:
impl<T> Iterator for IntoIter<T> {
type Item = T;
fn next(&mut self) -> Option<T> {
if self.start == self.end {
None
} else {
unsafe {
let result = ptr::read(self.start);
self.start = self.start.offset(1);
Some(result)
}
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
let len = (self.end as usize - self.start as usize)
/ mem::size_of::<T>();
(len, Some(len))
}
}
And here's iterating backwards.
impl<T> DoubleEndedIterator for IntoIter<T> {
fn next_back(&mut self) -> Option<T> {
if self.start == self.end {
None
} else {
unsafe {
self.end = self.end.offset(-1);
Some(ptr::read(self.end))
}
}
}
}
Because IntoIter takes ownership of its allocation, it needs to implement Drop to free it. However it also wants to implement Drop to drop any elements it contains that weren't yielded.
impl<T> Drop for IntoIter<T> {
fn drop(&mut self) {
if self.cap != 0 {
// drop any remaining elements
for _ in &mut *self {}
let layout = Layout::array::<T>(self.cap).unwrap();
unsafe {
alloc::dealloc(self.buf.as_ptr() as *mut u8, layout);
}
}
}
}
RawVec
We've actually reached an interesting situation here: we've duplicated the logic for specifying a buffer and freeing its memory in Vec and IntoIter. Now that we've implemented it and identified actual logic duplication, this is a good time to perform some logic compression.
We're going to abstract out the (ptr, cap)
pair and give them the logic for
allocating, growing, and freeing:
struct RawVec<T> {
ptr: NonNull<T>,
cap: usize,
}
unsafe impl<T: Send> Send for RawVec<T> {}
unsafe impl<T: Sync> Sync for RawVec<T> {}
impl<T> RawVec<T> {
fn new() -> Self {
assert!(mem::size_of::<T>() != 0, "TODO: implement ZST support");
RawVec {
ptr: NonNull::dangling(),
cap: 0,
}
}
fn grow(&mut self) {
// This can't overflow because we ensure self.cap <= isize::MAX.
let new_cap = if self.cap == 0 { 1 } else { 2 * self.cap };
// Layout::array checks that the number of bytes is <= usize::MAX,
// but this is redundant since old_layout.size() <= isize::MAX,
// so the `unwrap` should never fail.
let new_layout = Layout::array::<T>(new_cap).unwrap();
// Ensure that the new allocation doesn't exceed `isize::MAX` bytes.
assert!(new_layout.size() <= isize::MAX as usize, "Allocation too large");
let new_ptr = if self.cap == 0 {
unsafe { alloc::alloc(new_layout) }
} else {
let old_layout = Layout::array::<T>(self.cap).unwrap();
let old_ptr = self.ptr.as_ptr() as *mut u8;
unsafe { alloc::realloc(old_ptr, old_layout, new_layout.size()) }
};
// If allocation fails, `new_ptr` will be null, in which case we abort.
self.ptr = match NonNull::new(new_ptr as *mut T) {
Some(p) => p,
None => alloc::handle_alloc_error(new_layout),
};
self.cap = new_cap;
}
}
impl<T> Drop for RawVec<T> {
fn drop(&mut self) {
if self.cap != 0 {
let layout = Layout::array::<T>(self.cap).unwrap();
unsafe {
alloc::dealloc(self.ptr.as_ptr() as *mut u8, layout);
}
}
}
}
And change Vec as follows:
pub struct Vec<T> {
buf: RawVec<T>,
len: usize,
}
impl<T> Vec<T> {
fn ptr(&self) -> *mut T {
self.buf.ptr.as_ptr()
}
fn cap(&self) -> usize {
self.buf.cap
}
pub fn new() -> Self {
Vec {
buf: RawVec::new(),
len: 0,
}
}
// push/pop/insert/remove largely unchanged:
// * `self.ptr.as_ptr() -> self.ptr()`
// * `self.cap -> self.cap()`
// * `self.grow() -> self.buf.grow()`
}
impl<T> Drop for Vec<T> {
fn drop(&mut self) {
while let Some(_) = self.pop() {}
// deallocation is handled by RawVec
}
}
And finally we can really simplify IntoIter:
pub struct IntoIter<T> {
_buf: RawVec<T>, // we don't actually care about this. Just need it to live.
start: *const T,
end: *const T,
}
// next and next_back literally unchanged since they never referred to the buf
impl<T> Drop for IntoIter<T> {
fn drop(&mut self) {
// only need to ensure all our elements are read;
// buffer will clean itself up afterwards.
for _ in &mut *self {}
}
}
impl<T> IntoIterator for Vec<T> {
type Item = T;
type IntoIter = IntoIter<T>;
fn into_iter(self) -> IntoIter<T> {
// need to use ptr::read to unsafely move the buf out since it's
// not Copy, and Vec implements Drop (so we can't destructure it).
let buf = unsafe { ptr::read(&self.buf) };
let len = self.len;
mem::forget(self);
IntoIter {
start: buf.ptr.as_ptr(),
end: if buf.cap == 0 {
// can't offset off of a pointer unless it's part of an allocation
buf.ptr.as_ptr()
} else {
unsafe { buf.ptr.as_ptr().add(len) }
},
_buf: buf,
}
}
}
Much better.
Drain
Let's move on to Drain. Drain is largely the same as IntoIter, except that instead of consuming the Vec, it borrows the Vec and leaves its allocation untouched. For now we'll only implement the "basic" full-range version.
use std::marker::PhantomData;
struct Drain<'a, T: 'a> {
// Need to bound the lifetime here, so we do it with `&'a mut Vec<T>`
// because that's semantically what we contain. We're "just" calling
// `pop()` and `remove(0)`.
vec: PhantomData<&'a mut Vec<T>>,
start: *const T,
end: *const T,
}
impl<'a, T> Iterator for Drain<'a, T> {
type Item = T;
fn next(&mut self) -> Option<T> {
if self.start == self.end {
None
-- wait, this is seeming familiar. Let's do some more compression. Both IntoIter and Drain have the exact same structure, let's just factor it out.
struct RawValIter<T> {
start: *const T,
end: *const T,
}
impl<T> RawValIter<T> {
// unsafe to construct because it has no associated lifetimes.
// This is necessary to store a RawValIter in the same struct as
// its actual allocation. OK since it's a private implementation
// detail.
unsafe fn new(slice: &[T]) -> Self {
RawValIter {
start: slice.as_ptr(),
end: if slice.len() == 0 {
// if `len = 0`, then this is not actually allocated memory.
// Need to avoid offsetting because that will give wrong
// information to LLVM via GEP.
slice.as_ptr()
} else {
slice.as_ptr().add(slice.len())
}
}
}
}
// Iterator and DoubleEndedIterator impls identical to IntoIter.
And IntoIter becomes the following:
pub struct IntoIter<T> {
_buf: RawVec<T>, // we don't actually care about this. Just need it to live.
iter: RawValIter<T>,
}
impl<T> Iterator for IntoIter<T> {
type Item = T;
fn next(&mut self) -> Option<T> { self.iter.next() }
fn size_hint(&self) -> (usize, Option<usize>) { self.iter.size_hint() }
}
impl<T> DoubleEndedIterator for IntoIter<T> {
fn next_back(&mut self) -> Option<T> { self.iter.next_back() }
}
impl<T> Drop for IntoIter<T> {
fn drop(&mut self) {
for _ in &mut *self {}
}
}
impl<T> IntoIterator for Vec<T> {
type Item = T;
type IntoIter = IntoIter<T>;
fn into_iter(self) -> IntoIter<T> {
unsafe {
let iter = RawValIter::new(&self);
let buf = ptr::read(&self.buf);
mem::forget(self);
IntoIter {
iter,
_buf: buf,
}
}
}
}
Note that I've left a few quirks in this design to make upgrading Drain to work with arbitrary subranges a bit easier. In particular we could have RawValIter drain itself on drop, but that won't work right for a more complex Drain. We also take a slice to simplify Drain initialization.
Alright, now Drain is really easy:
use std::marker::PhantomData;
pub struct Drain<'a, T: 'a> {
vec: PhantomData<&'a mut Vec<T>>,
iter: RawValIter<T>,
}
impl<'a, T> Iterator for Drain<'a, T> {
type Item = T;
fn next(&mut self) -> Option<T> { self.iter.next() }
fn size_hint(&self) -> (usize, Option<usize>) { self.iter.size_hint() }
}
impl<'a, T> DoubleEndedIterator for Drain<'a, T> {
fn next_back(&mut self) -> Option<T> { self.iter.next_back() }
}
impl<'a, T> Drop for Drain<'a, T> {
fn drop(&mut self) {
for _ in &mut *self {}
}
}
impl<T> Vec<T> {
pub fn drain(&mut self) -> Drain<T> {
let iter = unsafe { RawValIter::new(&self) };
// this is a mem::forget safety thing. If Drain is forgotten, we just
// leak the whole Vec's contents. Also we need to do this *eventually*
// anyway, so why not do it now?
self.len = 0;
Drain {
iter,
vec: PhantomData,
}
}
}
For more details on the mem::forget
problem, see the
section on leaks.
Handling Zero-Sized Types
It's time. We're going to fight the specter that is zero-sized types. Safe Rust never needs to care about this, but Vec is very intensive on raw pointers and raw allocations, which are exactly the two things that care about zero-sized types. We need to be careful of two things:
- The raw allocator API has undefined behavior if you pass in 0 for an allocation size.
- raw pointer offsets are no-ops for zero-sized types, which will break our C-style pointer iterator.
Thankfully we abstracted out pointer-iterators and allocating handling into
RawValIter
and RawVec
respectively. How mysteriously convenient.
Allocating Zero-Sized Types
So if the allocator API doesn't support zero-sized allocations, what on earth
do we store as our allocation? NonNull::dangling()
of course! Almost every operation
with a ZST is a no-op since ZSTs have exactly one value, and therefore no state needs
to be considered to store or load them. This actually extends to ptr::read
and
ptr::write
: they won't actually look at the pointer at all. As such we never need
to change the pointer.
Note however that our previous reliance on running out of memory before overflow is no longer valid with zero-sized types. We must explicitly guard against capacity overflow for zero-sized types.
Due to our current architecture, all this means is writing 3 guards, one in each
method of RawVec
.
impl<T> RawVec<T> {
fn new() -> Self {
// This branch should be stripped at compile time.
let cap = if mem::size_of::<T>() == 0 { usize::MAX } else { 0 };
// `NonNull::dangling()` doubles as "unallocated" and "zero-sized allocation"
RawVec {
ptr: NonNull::dangling(),
cap,
}
}
fn grow(&mut self) {
// since we set the capacity to usize::MAX when T has size 0,
// getting to here necessarily means the Vec is overfull.
assert!(mem::size_of::<T>() != 0, "capacity overflow");
let (new_cap, new_layout) = if self.cap == 0 {
(1, Layout::array::<T>(1).unwrap())
} else {
// This can't overflow because we ensure self.cap <= isize::MAX.
let new_cap = 2 * self.cap;
// `Layout::array` checks that the number of bytes is <= usize::MAX,
// but this is redundant since old_layout.size() <= isize::MAX,
// so the `unwrap` should never fail.
let new_layout = Layout::array::<T>(new_cap).unwrap();
(new_cap, new_layout)
};
// Ensure that the new allocation doesn't exceed `isize::MAX` bytes.
assert!(new_layout.size() <= isize::MAX as usize, "Allocation too large");
let new_ptr = if self.cap == 0 {
unsafe { alloc::alloc(new_layout) }
} else {
let old_layout = Layout::array::<T>(self.cap).unwrap();
let old_ptr = self.ptr.as_ptr() as *mut u8;
unsafe { alloc::realloc(old_ptr, old_layout, new_layout.size()) }
};
// If allocation fails, `new_ptr` will be null, in which case we abort.
self.ptr = match NonNull::new(new_ptr as *mut T) {
Some(p) => p,
None => alloc::handle_alloc_error(new_layout),
};
self.cap = new_cap;
}
}
impl<T> Drop for RawVec<T> {
fn drop(&mut self) {
let elem_size = mem::size_of::<T>();
if self.cap != 0 && elem_size != 0 {
unsafe {
alloc::dealloc(
self.ptr.as_ptr() as *mut u8,
Layout::array::<T>(self.cap).unwrap(),
);
}
}
}
}
That's it. We support pushing and popping zero-sized types now. Our iterators (that aren't provided by slice Deref) are still busted, though.
Iterating Zero-Sized Types
Zero-sized offsets are no-ops. This means that our current design will always
initialize start
and end
as the same value, and our iterators will yield
nothing. The current solution to this is to cast the pointers to integers,
increment, and then cast them back:
impl<T> RawValIter<T> {
unsafe fn new(slice: &[T]) -> Self {
RawValIter {
start: slice.as_ptr(),
end: if mem::size_of::<T>() == 0 {
((slice.as_ptr() as usize) + slice.len()) as *const _
} else if slice.len() == 0 {
slice.as_ptr()
} else {
slice.as_ptr().add(slice.len())
},
}
}
}
Now we have a different bug. Instead of our iterators not running at all, our
iterators now run forever. We need to do the same trick in our iterator impls.
Also, our size_hint computation code will divide by 0 for ZSTs. Since we'll
basically be treating the two pointers as if they point to bytes, we'll just
map size 0 to divide by 1. Here's what next
will be:
fn next(&mut self) -> Option<T> {
if self.start == self.end {
None
} else {
unsafe {
let result = ptr::read(self.start);
self.start = if mem::size_of::<T>() == 0 {
(self.start as usize + 1) as *const _
} else {
self.start.offset(1)
};
Some(result)
}
}
}
Do you see the "bug"? No one else did! The original author only noticed the problem when linking to this page years later. This code is kind of dubious because abusing the iterator pointers to be counters makes them unaligned! Our one job when using ZSTs is to keep pointers aligned! forehead slap
Raw pointers don't need to be aligned at all times, so the basic trick of
using pointers as counters is fine, but they should definitely be aligned
when passed to ptr::read
! This is possibly needless pedantry
because ptr::read
is a noop for a ZST, but let's be a little more
responsible and read from NonNull::dangling
on the ZST path.
(Alternatively you could call read_unaligned
on the ZST path. Either is fine,
because either way we're making up a value from nothing and it all compiles
to doing nothing.)
impl<T> Iterator for RawValIter<T> {
type Item = T;
fn next(&mut self) -> Option<T> {
if self.start == self.end {
None
} else {
unsafe {
if mem::size_of::<T>() == 0 {
self.start = (self.start as usize + 1) as *const _;
Some(ptr::read(NonNull::<T>::dangling().as_ptr()))
} else {
let old_ptr = self.start;
self.start = self.start.offset(1);
Some(ptr::read(old_ptr))
}
}
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
let elem_size = mem::size_of::<T>();
let len = (self.end as usize - self.start as usize)
/ if elem_size == 0 { 1 } else { elem_size };
(len, Some(len))
}
}
impl<T> DoubleEndedIterator for RawValIter<T> {
fn next_back(&mut self) -> Option<T> {
if self.start == self.end {
None
} else {
unsafe {
if mem::size_of::<T>() == 0 {
self.end = (self.end as usize - 1) as *const _;
Some(ptr::read(NonNull::<T>::dangling().as_ptr()))
} else {
self.end = self.end.offset(-1);
Some(ptr::read(self.end))
}
}
}
}
}
And that's it. Iteration works!
The Final Code
use std::alloc::{self, Layout}; use std::marker::PhantomData; use std::mem; use std::ops::{Deref, DerefMut}; use std::ptr::{self, NonNull}; struct RawVec<T> { ptr: NonNull<T>, cap: usize, } unsafe impl<T: Send> Send for RawVec<T> {} unsafe impl<T: Sync> Sync for RawVec<T> {} impl<T> RawVec<T> { fn new() -> Self { // !0 is usize::MAX. This branch should be stripped at compile time. let cap = if mem::size_of::<T>() == 0 { !0 } else { 0 }; // `NonNull::dangling()` doubles as "unallocated" and "zero-sized allocation" RawVec { ptr: NonNull::dangling(), cap, } } fn grow(&mut self) { // since we set the capacity to usize::MAX when T has size 0, // getting to here necessarily means the Vec is overfull. assert!(mem::size_of::<T>() != 0, "capacity overflow"); let (new_cap, new_layout) = if self.cap == 0 { (1, Layout::array::<T>(1).unwrap()) } else { // This can't overflow because we ensure self.cap <= isize::MAX. let new_cap = 2 * self.cap; // `Layout::array` checks that the number of bytes is <= usize::MAX, // but this is redundant since old_layout.size() <= isize::MAX, // so the `unwrap` should never fail. let new_layout = Layout::array::<T>(new_cap).unwrap(); (new_cap, new_layout) }; // Ensure that the new allocation doesn't exceed `isize::MAX` bytes. assert!( new_layout.size() <= isize::MAX as usize, "Allocation too large" ); let new_ptr = if self.cap == 0 { unsafe { alloc::alloc(new_layout) } } else { let old_layout = Layout::array::<T>(self.cap).unwrap(); let old_ptr = self.ptr.as_ptr() as *mut u8; unsafe { alloc::realloc(old_ptr, old_layout, new_layout.size()) } }; // If allocation fails, `new_ptr` will be null, in which case we abort. self.ptr = match NonNull::new(new_ptr as *mut T) { Some(p) => p, None => alloc::handle_alloc_error(new_layout), }; self.cap = new_cap; } } impl<T> Drop for RawVec<T> { fn drop(&mut self) { let elem_size = mem::size_of::<T>(); if self.cap != 0 && elem_size != 0 { unsafe { alloc::dealloc( self.ptr.as_ptr() as *mut u8, Layout::array::<T>(self.cap).unwrap(), ); } } } } pub struct Vec<T> { buf: RawVec<T>, len: usize, } impl<T> Vec<T> { fn ptr(&self) -> *mut T { self.buf.ptr.as_ptr() } fn cap(&self) -> usize { self.buf.cap } pub fn new() -> Self { Vec { buf: RawVec::new(), len: 0, } } pub fn push(&mut self, elem: T) { if self.len == self.cap() { self.buf.grow(); } unsafe { ptr::write(self.ptr().add(self.len), elem); } // Can't overflow, we'll OOM first. self.len += 1; } pub fn pop(&mut self) -> Option<T> { if self.len == 0 { None } else { self.len -= 1; unsafe { Some(ptr::read(self.ptr().add(self.len))) } } } pub fn insert(&mut self, index: usize, elem: T) { assert!(index <= self.len, "index out of bounds"); if self.len == self.cap() { self.buf.grow(); } unsafe { ptr::copy( self.ptr().add(index), self.ptr().add(index + 1), self.len - index, ); ptr::write(self.ptr().add(index), elem); } self.len += 1; } pub fn remove(&mut self, index: usize) -> T { assert!(index < self.len, "index out of bounds"); self.len -= 1; unsafe { let result = ptr::read(self.ptr().add(index)); ptr::copy( self.ptr().add(index + 1), self.ptr().add(index), self.len - index, ); result } } pub fn drain(&mut self) -> Drain<T> { let iter = unsafe { RawValIter::new(&self) }; // this is a mem::forget safety thing. If Drain is forgotten, we just // leak the whole Vec's contents. Also we need to do this *eventually* // anyway, so why not do it now? self.len = 0; Drain { iter, vec: PhantomData, } } } impl<T> Drop for Vec<T> { fn drop(&mut self) { while let Some(_) = self.pop() {} // deallocation is handled by RawVec } } impl<T> Deref for Vec<T> { type Target = [T]; fn deref(&self) -> &[T] { unsafe { std::slice::from_raw_parts(self.ptr(), self.len) } } } impl<T> DerefMut for Vec<T> { fn deref_mut(&mut self) -> &mut [T] { unsafe { std::slice::from_raw_parts_mut(self.ptr(), self.len) } } } impl<T> IntoIterator for Vec<T> { type Item = T; type IntoIter = IntoIter<T>; fn into_iter(self) -> IntoIter<T> { let (iter, buf) = unsafe { (RawValIter::new(&self), ptr::read(&self.buf)) }; mem::forget(self); IntoIter { iter, _buf: buf, } } } struct RawValIter<T> { start: *const T, end: *const T, } impl<T> RawValIter<T> { unsafe fn new(slice: &[T]) -> Self { RawValIter { start: slice.as_ptr(), end: if mem::size_of::<T>() == 0 { ((slice.as_ptr() as usize) + slice.len()) as *const _ } else if slice.len() == 0 { slice.as_ptr() } else { slice.as_ptr().add(slice.len()) }, } } } impl<T> Iterator for RawValIter<T> { type Item = T; fn next(&mut self) -> Option<T> { if self.start == self.end { None } else { unsafe { if mem::size_of::<T>() == 0 { self.start = (self.start as usize + 1) as *const _; Some(ptr::read(NonNull::<T>::dangling().as_ptr())) } else { let old_ptr = self.start; self.start = self.start.offset(1); Some(ptr::read(old_ptr)) } } } } fn size_hint(&self) -> (usize, Option<usize>) { let elem_size = mem::size_of::<T>(); let len = (self.end as usize - self.start as usize) / if elem_size == 0 { 1 } else { elem_size }; (len, Some(len)) } } impl<T> DoubleEndedIterator for RawValIter<T> { fn next_back(&mut self) -> Option<T> { if self.start == self.end { None } else { unsafe { if mem::size_of::<T>() == 0 { self.end = (self.end as usize - 1) as *const _; Some(ptr::read(NonNull::<T>::dangling().as_ptr())) } else { self.end = self.end.offset(-1); Some(ptr::read(self.end)) } } } } } pub struct IntoIter<T> { _buf: RawVec<T>, // we don't actually care about this. Just need it to live. iter: RawValIter<T>, } impl<T> Iterator for IntoIter<T> { type Item = T; fn next(&mut self) -> Option<T> { self.iter.next() } fn size_hint(&self) -> (usize, Option<usize>) { self.iter.size_hint() } } impl<T> DoubleEndedIterator for IntoIter<T> { fn next_back(&mut self) -> Option<T> { self.iter.next_back() } } impl<T> Drop for IntoIter<T> { fn drop(&mut self) { for _ in &mut *self {} } } pub struct Drain<'a, T: 'a> { vec: PhantomData<&'a mut Vec<T>>, iter: RawValIter<T>, } impl<'a, T> Iterator for Drain<'a, T> { type Item = T; fn next(&mut self) -> Option<T> { self.iter.next() } fn size_hint(&self) -> (usize, Option<usize>) { self.iter.size_hint() } } impl<'a, T> DoubleEndedIterator for Drain<'a, T> { fn next_back(&mut self) -> Option<T> { self.iter.next_back() } } impl<'a, T> Drop for Drain<'a, T> { fn drop(&mut self) { // pre-drain the iter for _ in &mut *self {} } } fn main() { tests::create_push_pop(); tests::iter_test(); tests::test_drain(); tests::test_zst(); println!("All tests finished OK"); } mod tests { use super::*; pub fn create_push_pop() { let mut v = Vec::new(); v.push(1); assert_eq!(1, v.len()); assert_eq!(1, v[0]); for i in v.iter_mut() { *i += 1; } v.insert(0, 5); let x = v.pop(); assert_eq!(Some(2), x); assert_eq!(1, v.len()); v.push(10); let x = v.remove(0); assert_eq!(5, x); assert_eq!(1, v.len()); } pub fn iter_test() { let mut v = Vec::new(); for i in 0..10 { v.push(Box::new(i)) } let mut iter = v.into_iter(); let first = iter.next().unwrap(); let last = iter.next_back().unwrap(); drop(iter); assert_eq!(0, *first); assert_eq!(9, *last); } pub fn test_drain() { let mut v = Vec::new(); for i in 0..10 { v.push(Box::new(i)) } { let mut drain = v.drain(); let first = drain.next().unwrap(); let last = drain.next_back().unwrap(); assert_eq!(0, *first); assert_eq!(9, *last); } assert_eq!(0, v.len()); v.push(Box::new(1)); assert_eq!(1, *v.pop().unwrap()); } pub fn test_zst() { let mut v = Vec::new(); for _i in 0..10 { v.push(()) } let mut count = 0; for _ in v.into_iter() { count += 1 } assert_eq!(10, count); } }
Implementing Arc and Mutex
Knowing the theory is all fine and good, but the best way to understand
something is to use it. To better understand atomics and interior mutability,
we'll be implementing versions of the standard library's Arc
and Mutex
types.
TODO: Write Mutex
chapters.
Implementing Arc
In this section, we'll be implementing a simpler version of std::sync::Arc
.
Similarly to the implementation of Vec
we made earlier, we won't be
taking advantage of as many optimizations, intrinsics, or unstable code as the
standard library may.
This implementation is loosely based on the standard library's implementation
(technically taken from alloc::sync
in 1.49, as that's where it's actually
implemented), but it will not support weak references at the moment as they
make the implementation slightly more complex.
Please note that this section is very work-in-progress at the moment.
Layout
Let's start by making the layout for our implementation of Arc
.
An Arc<T>
provides thread-safe shared ownership of a value of type T
,
allocated in the heap. Sharing implies immutability in Rust, so we don't need to
design anything that manages access to that value, right? Although interior
mutability types like Mutex allow Arc's users to create shared mutability, Arc
itself doesn't need to concern itself with these issues.
However there is one place where Arc needs to concern itself with mutation:
destruction. When all the owners of the Arc go away, we need to be able to
drop
its contents and free its allocation. So we need a way for an owner to
know if it's the last owner, and the simplest way to do that is with a count
of the owners -- Reference Counting.
Unfortunately, this reference count is inherently shared mutable state, so Arc does need to think about synchronization. We could use a Mutex for this, but that's overkill. Instead, we'll use atomics. And since everyone already needs a pointer to the T's allocation, we might as well put the reference count in that same allocation.
Naively, it would look something like this:
#![allow(unused)] fn main() { use std::sync::atomic; pub struct Arc<T> { ptr: *mut ArcInner<T>, } pub struct ArcInner<T> { rc: atomic::AtomicUsize, data: T, } }
This would compile, however it would be incorrect. First of all, the compiler
will give us too strict variance. For example, an Arc<&'static str>
couldn't
be used where an Arc<&'a str>
was expected. More importantly, it will give
incorrect ownership information to the drop checker, as it will assume we don't
own any values of type T
. As this is a structure providing shared ownership of
a value, at some point there will be an instance of this structure that entirely
owns its data. See the chapter on ownership and lifetimes for
all the details on variance and drop check.
To fix the first problem, we can use NonNull<T>
. Note that NonNull<T>
is a
wrapper around a raw pointer that declares that:
- We are covariant over
T
- Our pointer is never null
To fix the second problem, we can include a PhantomData
marker containing an
ArcInner<T>
. This will tell the drop checker that we have some notion of
ownership of a value of ArcInner<T>
(which itself contains some T
).
With these changes we get our final structure:
#![allow(unused)] fn main() { use std::marker::PhantomData; use std::ptr::NonNull; use std::sync::atomic::AtomicUsize; pub struct Arc<T> { ptr: NonNull<ArcInner<T>>, phantom: PhantomData<ArcInner<T>>, } pub struct ArcInner<T> { rc: AtomicUsize, data: T, } }
Base Code
Now that we've decided the layout for our implementation of Arc
, let's create
some basic code.
Constructing the Arc
We'll first need a way to construct an Arc<T>
.
This is pretty simple, as we just need to box the ArcInner<T>
and get a
NonNull<T>
pointer to it.
impl<T> Arc<T> {
pub fn new(data: T) -> Arc<T> {
// We start the reference count at 1, as that first reference is the
// current pointer.
let boxed = Box::new(ArcInner {
rc: AtomicUsize::new(1),
data,
});
Arc {
// It is okay to call `.unwrap()` here as we get a pointer from
// `Box::into_raw` which is guaranteed to not be null.
ptr: NonNull::new(Box::into_raw(boxed)).unwrap(),
phantom: PhantomData,
}
}
}
Send and Sync
Since we're building a concurrency primitive, we'll need to be able to send it
across threads. Thus, we can implement the Send
and Sync
marker traits. For
more information on these, see the section on Send
and
Sync
.
This is okay because:
- You can only get a mutable reference to the value inside an
Arc
if and only if it is the onlyArc
referencing that data (which only happens inDrop
) - We use atomics for the shared mutable reference counting
unsafe impl<T: Sync + Send> Send for Arc<T> {}
unsafe impl<T: Sync + Send> Sync for Arc<T> {}
We need to have the bound T: Sync + Send
because if we did not provide those
bounds, it would be possible to share values that are thread-unsafe across a
thread boundary via an Arc
, which could possibly cause data races or
unsoundness.
For example, if those bounds were not present, Arc<Rc<u32>>
would be Sync
or
Send
, meaning that you could clone the Rc
out of the Arc
to send it across
a thread (without creating an entirely new Rc
), which would create data races
as Rc
is not thread-safe.
Getting the ArcInner
To dereference the NonNull<T>
pointer into a &T
, we can call
NonNull::as_ref
. This is unsafe, unlike the typical as_ref
function, so we
must call it like this:
unsafe { self.ptr.as_ref() }
We'll be using this snippet a few times in this code (usually with an associated
let
binding).
This unsafety is okay because while this Arc
is alive, we're guaranteed that
the inner pointer is valid.
Deref
Alright. Now we can make Arc
s (and soon will be able to clone and destroy them correctly), but how do we get
to the data inside?
What we need now is an implementation of Deref
.
We'll need to import the trait:
use std::ops::Deref;
And here's the implementation:
impl<T> Deref for Arc<T> {
type Target = T;
fn deref(&self) -> &T {
let inner = unsafe { self.ptr.as_ref() };
&inner.data
}
}
Pretty simple, eh? This simply dereferences the NonNull
pointer to the
ArcInner<T>
, then gets a reference to the data inside.
Code
Here's all the code from this section:
use std::ops::Deref;
impl<T> Arc<T> {
pub fn new(data: T) -> Arc<T> {
// We start the reference count at 1, as that first reference is the
// current pointer.
let boxed = Box::new(ArcInner {
rc: AtomicUsize::new(1),
data,
});
Arc {
// It is okay to call `.unwrap()` here as we get a pointer from
// `Box::into_raw` which is guaranteed to not be null.
ptr: NonNull::new(Box::into_raw(boxed)).unwrap(),
phantom: PhantomData,
}
}
}
unsafe impl<T: Sync + Send> Send for Arc<T> {}
unsafe impl<T: Sync + Send> Sync for Arc<T> {}
impl<T> Deref for Arc<T> {
type Target = T;
fn deref(&self) -> &T {
let inner = unsafe { self.ptr.as_ref() };
&inner.data
}
}
Cloning
Now that we've got some basic code set up, we'll need a way to clone the Arc
.
Basically, we need to:
- Increment the atomic reference count
- Construct a new instance of the
Arc
from the inner pointer
First, we need to get access to the ArcInner
:
let inner = unsafe { self.ptr.as_ref() };
We can update the atomic reference count as follows:
let old_rc = inner.rc.fetch_add(1, Ordering::???);
But what ordering should we use here? We don't really have any code that will
need atomic synchronization when cloning, as we do not modify the internal value
while cloning. Thus, we can use a Relaxed ordering here, which implies no
happens-before relationship but is atomic. When Drop
ping the Arc, however,
we'll need to atomically synchronize when decrementing the reference count. This
is described more in the section on the Drop
implementation for
Arc
. For more information on atomic relationships and Relaxed
ordering, see the section on atomics.
Thus, the code becomes this:
let old_rc = inner.rc.fetch_add(1, Ordering::Relaxed);
We'll need to add another import to use Ordering
:
#![allow(unused)] fn main() { use std::sync::atomic::Ordering; }
However, we have one problem with this implementation right now. What if someone
decides to mem::forget
a bunch of Arcs? The code we have written so far (and
will write) assumes that the reference count accurately portrays how many Arcs
are in memory, but with mem::forget
this is false. Thus, when more and more
Arcs are cloned from this one without them being Drop
ped and the reference
count being decremented, we can overflow! This will cause use-after-free which
is INCREDIBLY BAD!
To handle this, we need to check that the reference count does not go over some
arbitrary value (below usize::MAX
, as we're storing the reference count as an
AtomicUsize
), and do something.
The standard library's implementation decides to just abort the program (as it
is an incredibly unlikely case in normal code and if it happens, the program is
probably incredibly degenerate) if the reference count reaches isize::MAX
(about half of usize::MAX
) on any thread, on the assumption that there are
probably not about 2 billion threads (or about 9 quintillion on some 64-bit
machines) incrementing the reference count at once. This is what we'll do.
It's pretty simple to implement this behavior:
if old_rc >= isize::MAX as usize {
std::process::abort();
}
Then, we need to return a new instance of the Arc
:
Self {
ptr: self.ptr,
phantom: PhantomData
}
Now, let's wrap this all up inside the Clone
implementation:
use std::sync::atomic::Ordering;
impl<T> Clone for Arc<T> {
fn clone(&self) -> Arc<T> {
let inner = unsafe { self.ptr.as_ref() };
// Using a relaxed ordering is alright here as we don't need any atomic
// synchronization here as we're not modifying or accessing the inner
// data.
let old_rc = inner.rc.fetch_add(1, Ordering::Relaxed);
if old_rc >= isize::MAX as usize {
std::process::abort();
}
Self {
ptr: self.ptr,
phantom: PhantomData,
}
}
}
Dropping
We now need a way to decrease the reference count and drop the data once it is low enough, otherwise the data will live forever on the heap.
To do this, we can implement Drop
.
Basically, we need to:
- Decrement the reference count
- If there is only one reference remaining to the data, then:
- Atomically fence the data to prevent reordering of the use and deletion of the data
- Drop the inner data
First, we'll need to get access to the ArcInner
:
let inner = unsafe { self.ptr.as_ref() };
Now, we need to decrement the reference count. To streamline our code, we can
also return if the returned value from fetch_sub
(the value of the reference
count before decrementing it) is not equal to 1
(which happens when we are not
the last reference to the data).
if inner.rc.fetch_sub(1, Ordering::Release) != 1 {
return;
}
We then need to create an atomic fence to prevent reordering of the use of the
data and deletion of the data. As described in the standard library's
implementation of Arc
:
This fence is needed to prevent reordering of use of the data and deletion of the data. Because it is marked
Release
, the decreasing of the reference count synchronizes with thisAcquire
fence. This means that use of the data happens before decreasing the reference count, which happens before this fence, which happens before the deletion of the data.As explained in the Boost documentation,
It is important to enforce any possible access to the object in one thread (through an existing reference) to happen before deleting the object in a different thread. This is achieved by a "release" operation after dropping a reference (any access to the object through this reference must obviously happened before), and an "acquire" operation before deleting the object.
In particular, while the contents of an Arc are usually immutable, it's possible to have interior writes to something like a
Mutex<T>
. Since a Mutex is not acquired when it is deleted, we can't rely on its synchronization logic to make writes in thread A visible to a destructor running in thread B.Also note that the Acquire fence here could probably be replaced with an Acquire load, which could improve performance in highly-contended situations. See 2.
To do this, we do the following:
#![allow(unused)] fn main() { use std::sync::atomic::Ordering; use std::sync::atomic; atomic::fence(Ordering::Acquire); }
Finally, we can drop the data itself. We use Box::from_raw
to drop the boxed
ArcInner<T>
and its data. This takes a *mut T
and not a NonNull<T>
, so we
must convert using NonNull::as_ptr
.
unsafe { Box::from_raw(self.ptr.as_ptr()); }
This is safe as we know we have the last pointer to the ArcInner
and that its
pointer is valid.
Now, let's wrap this all up inside the Drop
implementation:
impl<T> Drop for Arc<T> {
fn drop(&mut self) {
let inner = unsafe { self.ptr.as_ref() };
if inner.rc.fetch_sub(1, Ordering::Release) != 1 {
return;
}
// This fence is needed to prevent reordering of the use and deletion
// of the data.
atomic::fence(Ordering::Acquire);
// This is safe as we know we have the last pointer to the `ArcInner`
// and that its pointer is valid.
unsafe { Box::from_raw(self.ptr.as_ptr()); }
}
}
Final Code
Here's the final code, with some added comments and re-ordered imports:
#![allow(unused)] fn main() { use std::marker::PhantomData; use std::ops::Deref; use std::ptr::NonNull; use std::sync::atomic::{self, AtomicUsize, Ordering}; pub struct Arc<T> { ptr: NonNull<ArcInner<T>>, phantom: PhantomData<ArcInner<T>>, } pub struct ArcInner<T> { rc: AtomicUsize, data: T, } impl<T> Arc<T> { pub fn new(data: T) -> Arc<T> { // We start the reference count at 1, as that first reference is the // current pointer. let boxed = Box::new(ArcInner { rc: AtomicUsize::new(1), data, }); Arc { // It is okay to call `.unwrap()` here as we get a pointer from // `Box::into_raw` which is guaranteed to not be null. ptr: NonNull::new(Box::into_raw(boxed)).unwrap(), phantom: PhantomData, } } } unsafe impl<T: Sync + Send> Send for Arc<T> {} unsafe impl<T: Sync + Send> Sync for Arc<T> {} impl<T> Deref for Arc<T> { type Target = T; fn deref(&self) -> &T { let inner = unsafe { self.ptr.as_ref() }; &inner.data } } impl<T> Clone for Arc<T> { fn clone(&self) -> Arc<T> { let inner = unsafe { self.ptr.as_ref() }; // Using a relaxed ordering is alright here as we don't need any atomic // synchronization here as we're not modifying or accessing the inner // data. let old_rc = inner.rc.fetch_add(1, Ordering::Relaxed); if old_rc >= isize::MAX as usize { std::process::abort(); } Self { ptr: self.ptr, phantom: PhantomData, } } } impl<T> Drop for Arc<T> { fn drop(&mut self) { let inner = unsafe { self.ptr.as_ref() }; if inner.rc.fetch_sub(1, Ordering::Release) != 1 { return; } // This fence is needed to prevent reordering of the use and deletion // of the data. atomic::fence(Ordering::Acquire); // This is safe as we know we have the last pointer to the `ArcInner` // and that its pointer is valid. unsafe { Box::from_raw(self.ptr.as_ptr()); } } } }
Foreign Function Interface
Introduction
This guide will use the snappy
compression/decompression library as an introduction to writing bindings for
foreign code. Rust is currently unable to call directly into a C++ library, but
snappy includes a C interface (documented in
snappy-c.h
).
A note about libc
Many of these examples use the libc
crate, which provides various
type definitions for C types, among other things. If you’re trying these
examples yourself, you’ll need to add libc
to your Cargo.toml
:
[dependencies]
libc = "0.2.0"
Calling foreign functions
The following is a minimal example of calling a foreign function which will compile if snappy is installed:
use libc::size_t;
#[link(name = "snappy")]
extern {
fn snappy_max_compressed_length(source_length: size_t) -> size_t;
}
fn main() {
let x = unsafe { snappy_max_compressed_length(100) };
println!("max compressed length of a 100 byte buffer: {}", x);
}
The extern
block is a list of function signatures in a foreign library, in
this case with the platform's C ABI. The #[link(...)]
attribute is used to
instruct the linker to link against the snappy library so the symbols are
resolved.
Foreign functions are assumed to be unsafe so calls to them need to be wrapped
with unsafe {}
as a promise to the compiler that everything contained within
truly is safe. C libraries often expose interfaces that aren't thread-safe, and
almost any function that takes a pointer argument isn't valid for all possible
inputs since the pointer could be dangling, and raw pointers fall outside of
Rust's safe memory model.
When declaring the argument types to a foreign function, the Rust compiler cannot check if the declaration is correct, so specifying it correctly is part of keeping the binding correct at runtime.
The extern
block can be extended to cover the entire snappy API:
use libc::{c_int, size_t};
#[link(name = "snappy")]
extern {
fn snappy_compress(input: *const u8,
input_length: size_t,
compressed: *mut u8,
compressed_length: *mut size_t) -> c_int;
fn snappy_uncompress(compressed: *const u8,
compressed_length: size_t,
uncompressed: *mut u8,
uncompressed_length: *mut size_t) -> c_int;
fn snappy_max_compressed_length(source_length: size_t) -> size_t;
fn snappy_uncompressed_length(compressed: *const u8,
compressed_length: size_t,
result: *mut size_t) -> c_int;
fn snappy_validate_compressed_buffer(compressed: *const u8,
compressed_length: size_t) -> c_int;
}
fn main() {}
Creating a safe interface
The raw C API needs to be wrapped to provide memory safety and make use of higher-level concepts like vectors. A library can choose to expose only the safe, high-level interface and hide the unsafe internal details.
Wrapping the functions which expect buffers involves using the slice::raw
module to manipulate Rust
vectors as pointers to memory. Rust's vectors are guaranteed to be a contiguous block of memory. The
length is the number of elements currently contained, and the capacity is the total size in elements of
the allocated memory. The length is less than or equal to the capacity.
use libc::{c_int, size_t};
unsafe fn snappy_validate_compressed_buffer(_: *const u8, _: size_t) -> c_int { 0 }
fn main() {}
pub fn validate_compressed_buffer(src: &[u8]) -> bool {
unsafe {
snappy_validate_compressed_buffer(src.as_ptr(), src.len() as size_t) == 0
}
}
The validate_compressed_buffer
wrapper above makes use of an unsafe
block, but it makes the
guarantee that calling it is safe for all inputs by leaving off unsafe
from the function
signature.
The snappy_compress
and snappy_uncompress
functions are more complex, since a buffer has to be
allocated to hold the output too.
The snappy_max_compressed_length
function can be used to allocate a vector with the maximum
required capacity to hold the compressed output. The vector can then be passed to the
snappy_compress
function as an output parameter. An output parameter is also passed to retrieve
the true length after compression for setting the length.
use libc::{size_t, c_int};
unsafe fn snappy_compress(a: *const u8, b: size_t, c: *mut u8,
d: *mut size_t) -> c_int { 0 }
unsafe fn snappy_max_compressed_length(a: size_t) -> size_t { a }
fn main() {}
pub fn compress(src: &[u8]) -> Vec<u8> {
unsafe {
let srclen = src.len() as size_t;
let psrc = src.as_ptr();
let mut dstlen = snappy_max_compressed_length(srclen);
let mut dst = Vec::with_capacity(dstlen as usize);
let pdst = dst.as_mut_ptr();
snappy_compress(psrc, srclen, pdst, &mut dstlen);
dst.set_len(dstlen as usize);
dst
}
}
Decompression is similar, because snappy stores the uncompressed size as part of the compression
format and snappy_uncompressed_length
will retrieve the exact buffer size required.
use libc::{size_t, c_int};
unsafe fn snappy_uncompress(compressed: *const u8,
compressed_length: size_t,
uncompressed: *mut u8,
uncompressed_length: *mut size_t) -> c_int { 0 }
unsafe fn snappy_uncompressed_length(compressed: *const u8,
compressed_length: size_t,
result: *mut size_t) -> c_int { 0 }
fn main() {}
pub fn uncompress(src: &[u8]) -> Option<Vec<u8>> {
unsafe {
let srclen = src.len() as size_t;
let psrc = src.as_ptr();
let mut dstlen: size_t = 0;
snappy_uncompressed_length(psrc, srclen, &mut dstlen);
let mut dst = Vec::with_capacity(dstlen as usize);
let pdst = dst.as_mut_ptr();
if snappy_uncompress(psrc, srclen, pdst, &mut dstlen) == 0 {
dst.set_len(dstlen as usize);
Some(dst)
} else {
None // SNAPPY_INVALID_INPUT
}
}
}
Then, we can add some tests to show how to use them.
use libc::{c_int, size_t};
unsafe fn snappy_compress(input: *const u8,
input_length: size_t,
compressed: *mut u8,
compressed_length: *mut size_t)
-> c_int { 0 }
unsafe fn snappy_uncompress(compressed: *const u8,
compressed_length: size_t,
uncompressed: *mut u8,
uncompressed_length: *mut size_t)
-> c_int { 0 }
unsafe fn snappy_max_compressed_length(source_length: size_t) -> size_t { 0 }
unsafe fn snappy_uncompressed_length(compressed: *const u8,
compressed_length: size_t,
result: *mut size_t)
-> c_int { 0 }
unsafe fn snappy_validate_compressed_buffer(compressed: *const u8,
compressed_length: size_t)
-> c_int { 0 }
fn main() { }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid() {
let d = vec![0xde, 0xad, 0xd0, 0x0d];
let c: &[u8] = &compress(&d);
assert!(validate_compressed_buffer(c));
assert!(uncompress(c) == Some(d));
}
#[test]
fn invalid() {
let d = vec![0, 0, 0, 0];
assert!(!validate_compressed_buffer(&d));
assert!(uncompress(&d).is_none());
}
#[test]
fn empty() {
let d = vec![];
assert!(!validate_compressed_buffer(&d));
assert!(uncompress(&d).is_none());
let c = compress(&d);
assert!(validate_compressed_buffer(&c));
assert!(uncompress(&c) == Some(d));
}
}
Destructors
Foreign libraries often hand off ownership of resources to the calling code. When this occurs, we must use Rust's destructors to provide safety and guarantee the release of these resources (especially in the case of panic).
For more about destructors, see the Drop trait.
Calling Rust code from C
You may wish to compile Rust code in a way so that it can be called from C. This is fairly easy, but requires a few things.
Rust side
First, we assume you have a lib crate named as rust_from_c
.
lib.rs
should have Rust code as following:
#[no_mangle] pub extern "C" fn hello_from_rust() { println!("Hello from Rust!"); } fn main() {}
The extern "C"
makes this function adhere to the C calling convention, as discussed below in "Foreign Calling Conventions".
The no_mangle
attribute turns off Rust's name mangling, so that it has a well defined symbol to link to.
Then, to compile Rust code as a shared library that can be called from C, add the following to your Cargo.toml
:
[lib]
crate-type = ["cdylib"]
(NOTE: We could also use the staticlib
crate type but it needs to tweak some linking flags.)
Run cargo build
and you're ready to go on the Rust side.
C side
We'll create a C file to call the hello_from_rust
function and compile it by gcc
.
C file should look like:
extern void hello_from_rust();
int main(void) {
hello_from_rust();
return 0;
}
We name the file as call_rust.c
and place it on the crate root.
Run the following to compile:
gcc call_rust.c -o call_rust -lrust_from_c -L./target/debug
-l
and -L
tell gcc to find our Rust library.
Finally, we can call Rust code from C with LD_LIBRARY_PATH
specified:
$ LD_LIBRARY_PATH=./target/debug ./call_rust
Hello from Rust!
That's it!
For more realistic example, check the cbindgen
.
Callbacks from C code to Rust functions
Some external libraries require the usage of callbacks to report back their
current state or intermediate data to the caller.
It is possible to pass functions defined in Rust to an external library.
The requirement for this is that the callback function is marked as extern
with the correct calling convention to make it callable from C code.
The callback function can then be sent through a registration call to the C library and afterwards be invoked from there.
A basic example is:
Rust code:
extern fn callback(a: i32) { println!("I'm called from C with value {0}", a); } #[link(name = "extlib")] extern { fn register_callback(cb: extern fn(i32)) -> i32; fn trigger_callback(); } fn main() { unsafe { register_callback(callback); trigger_callback(); // Triggers the callback. } }
C code:
typedef void (*rust_callback)(int32_t);
rust_callback cb;
int32_t register_callback(rust_callback callback) {
cb = callback;
return 1;
}
void trigger_callback() {
cb(7); // Will call callback(7) in Rust.
}
In this example Rust's main()
will call trigger_callback()
in C,
which would, in turn, call back to callback()
in Rust.
Targeting callbacks to Rust objects
The former example showed how a global function can be called from C code. However it is often desired that the callback is targeted to a special Rust object. This could be the object that represents the wrapper for the respective C object.
This can be achieved by passing a raw pointer to the object down to the C library. The C library can then include the pointer to the Rust object in the notification. This will allow the callback to unsafely access the referenced Rust object.
Rust code:
struct RustObject { a: i32, // Other members... } extern "C" fn callback(target: *mut RustObject, a: i32) { println!("I'm called from C with value {0}", a); unsafe { // Update the value in RustObject with the value received from the callback: (*target).a = a; } } #[link(name = "extlib")] extern { fn register_callback(target: *mut RustObject, cb: extern fn(*mut RustObject, i32)) -> i32; fn trigger_callback(); } fn main() { // Create the object that will be referenced in the callback: let mut rust_object = Box::new(RustObject { a: 5 }); unsafe { register_callback(&mut *rust_object, callback); trigger_callback(); } }
C code:
typedef void (*rust_callback)(void*, int32_t);
void* cb_target;
rust_callback cb;
int32_t register_callback(void* callback_target, rust_callback callback) {
cb_target = callback_target;
cb = callback;
return 1;
}
void trigger_callback() {
cb(cb_target, 7); // Will call callback(&rustObject, 7) in Rust.
}
Asynchronous callbacks
In the previously given examples the callbacks are invoked as a direct reaction to a function call to the external C library. The control over the current thread is switched from Rust to C to Rust for the execution of the callback, but in the end the callback is executed on the same thread that called the function which triggered the callback.
Things get more complicated when the external library spawns its own threads
and invokes callbacks from there.
In these cases access to Rust data structures inside the callbacks is
especially unsafe and proper synchronization mechanisms must be used.
Besides classical synchronization mechanisms like mutexes, one possibility in
Rust is to use channels (in std::sync::mpsc
) to forward data from the C
thread that invoked the callback into a Rust thread.
If an asynchronous callback targets a special object in the Rust address space it is also absolutely necessary that no more callbacks are performed by the C library after the respective Rust object gets destroyed. This can be achieved by unregistering the callback in the object's destructor and designing the library in a way that guarantees that no callback will be performed after deregistration.
Linking
The link
attribute on extern
blocks provides the basic building block for
instructing rustc how it will link to native libraries. There are two accepted
forms of the link attribute today:
#[link(name = "foo")]
#[link(name = "foo", kind = "bar")]
In both of these cases, foo
is the name of the native library that we're
linking to, and in the second case bar
is the type of native library that the
compiler is linking to. There are currently three known types of native
libraries:
- Dynamic -
#[link(name = "readline")]
- Static -
#[link(name = "my_build_dependency", kind = "static")]
- Frameworks -
#[link(name = "CoreFoundation", kind = "framework")]
Note that frameworks are only available on macOS targets.
The different kind
values are meant to differentiate how the native library
participates in linkage. From a linkage perspective, the Rust compiler creates
two flavors of artifacts: partial (rlib/staticlib) and final (dylib/binary).
Native dynamic library and framework dependencies are propagated to the final
artifact boundary, while static library dependencies are not propagated at
all, because the static libraries are integrated directly into the subsequent
artifact.
A few examples of how this model can be used are:
-
A native build dependency. Sometimes some C/C++ glue is needed when writing some Rust code, but distribution of the C/C++ code in a library format is a burden. In this case, the code will be archived into
libfoo.a
and then the Rust crate would declare a dependency via#[link(name = "foo", kind = "static")]
.Regardless of the flavor of output for the crate, the native static library will be included in the output, meaning that distribution of the native static library is not necessary.
-
A normal dynamic dependency. Common system libraries (like
readline
) are available on a large number of systems, and often a static copy of these libraries cannot be found. When this dependency is included in a Rust crate, partial targets (like rlibs) will not link to the library, but when the rlib is included in a final target (like a binary), the native library will be linked in.
On macOS, frameworks behave with the same semantics as a dynamic library.
Unsafe blocks
Some operations, like dereferencing raw pointers or calling functions that have been marked unsafe are only allowed inside unsafe blocks. Unsafe blocks isolate unsafety and are a promise to the compiler that the unsafety does not leak out of the block.
Unsafe functions, on the other hand, advertise it to the world. An unsafe function is written like this:
#![allow(unused)] fn main() { unsafe fn kaboom(ptr: *const i32) -> i32 { *ptr } }
This function can only be called from an unsafe
block or another unsafe
function.
Accessing foreign globals
Foreign APIs often export a global variable which could do something like track
global state. In order to access these variables, you declare them in extern
blocks with the static
keyword:
#[link(name = "readline")]
extern {
static rl_readline_version: libc::c_int;
}
fn main() {
println!("You have readline version {} installed.",
unsafe { rl_readline_version as i32 });
}
Alternatively, you may need to alter global state provided by a foreign
interface. To do this, statics can be declared with mut
so we can mutate
them.
use std::ffi::CString;
use std::ptr;
#[link(name = "readline")]
extern {
static mut rl_prompt: *const libc::c_char;
}
fn main() {
let prompt = CString::new("[my-awesome-shell] $").unwrap();
unsafe {
rl_prompt = prompt.as_ptr();
println!("{:?}", rl_prompt);
rl_prompt = ptr::null();
}
}
Note that all interaction with a static mut
is unsafe, both reading and
writing. Dealing with global mutable state requires a great deal of care.
Foreign calling conventions
Most foreign code exposes a C ABI, and Rust uses the platform's C calling convention by default when calling foreign functions. Some foreign functions, most notably the Windows API, use other calling conventions. Rust provides a way to tell the compiler which convention to use:
#[cfg(all(target_os = "win32", target_arch = "x86"))]
#[link(name = "kernel32")]
#[allow(non_snake_case)]
extern "stdcall" {
fn SetEnvironmentVariableA(n: *const u8, v: *const u8) -> libc::c_int;
}
fn main() { }
This applies to the entire extern
block. The list of supported ABI constraints
are:
stdcall
aapcs
cdecl
fastcall
thiscall
vectorcall
This is currently hidden behind theabi_vectorcall
gate and is subject to change.Rust
rust-intrinsic
system
C
win64
sysv64
Most of the abis in this list are self-explanatory, but the system
abi may
seem a little odd. This constraint selects whatever the appropriate ABI is for
interoperating with the target's libraries. For example, on win32 with a x86
architecture, this means that the abi used would be stdcall
. On x86_64,
however, windows uses the C
calling convention, so C
would be used. This
means that in our previous example, we could have used extern "system" { ... }
to define a block for all windows systems, not only x86 ones.
Interoperability with foreign code
Rust guarantees that the layout of a struct
is compatible with the platform's
representation in C only if the #[repr(C)]
attribute is applied to it.
#[repr(C, packed)]
can be used to lay out struct members without padding.
#[repr(C)]
can also be applied to an enum.
Rust's owned boxes (Box<T>
) use non-nullable pointers as handles which point
to the contained object. However, they should not be manually created because
they are managed by internal allocators. References can safely be assumed to be
non-nullable pointers directly to the type. However, breaking the borrow
checking or mutability rules is not guaranteed to be safe, so prefer using raw
pointers (*
) if that's needed because the compiler can't make as many
assumptions about them.
Vectors and strings share the same basic memory layout, and utilities are
available in the vec
and str
modules for working with C APIs. However,
strings are not terminated with \0
. If you need a NUL-terminated string for
interoperability with C, you should use the CString
type in the std::ffi
module.
The libc
crate on crates.io includes type aliases and function
definitions for the C standard library in the libc
module, and Rust links
against libc
and libm
by default.
Variadic functions
In C, functions can be 'variadic', meaning they accept a variable number of arguments. This can
be achieved in Rust by specifying ...
within the argument list of a foreign function declaration:
extern {
fn foo(x: i32, ...);
}
fn main() {
unsafe {
foo(10, 20, 30, 40, 50);
}
}
Normal Rust functions can not be variadic:
#![allow(unused)] fn main() { // This will not compile fn foo(x: i32, ...) {} }
The "nullable pointer optimization"
Certain Rust types are defined to never be null
. This includes references (&T
,
&mut T
), boxes (Box<T>
), and function pointers (extern "abi" fn()
). When
interfacing with C, pointers that might be null
are often used, which would seem to
require some messy transmute
s and/or unsafe code to handle conversions to/from Rust types.
However, trying to construct/work with these invalid values is undefined behavior,
so you should use the following workaround instead.
As a special case, an enum
is eligible for the "nullable pointer optimization" if it contains
exactly two variants, one of which contains no data and the other contains a field of one of the
non-nullable types listed above. This means no extra space is required for a discriminant; rather,
the empty variant is represented by putting a null
value into the non-nullable field. This is
called an "optimization", but unlike other optimizations it is guaranteed to apply to eligible
types.
The most common type that takes advantage of the nullable pointer optimization is Option<T>
,
where None
corresponds to null
. So Option<extern "C" fn(c_int) -> c_int>
is a correct way
to represent a nullable function pointer using the C ABI (corresponding to the C type
int (*)(int)
).
Here is a contrived example. Let's say some C library has a facility for registering a callback, which gets called in certain situations. The callback is passed a function pointer and an integer and it is supposed to run the function with the integer as a parameter. So we have function pointers flying across the FFI boundary in both directions.
use libc::c_int;
#[cfg(hidden)]
extern "C" {
/// Registers the callback.
fn register(cb: Option<extern "C" fn(Option<extern "C" fn(c_int) -> c_int>, c_int) -> c_int>);
}
unsafe fn register(_: Option<extern "C" fn(Option<extern "C" fn(c_int) -> c_int>,
c_int) -> c_int>)
{}
/// This fairly useless function receives a function pointer and an integer
/// from C, and returns the result of calling the function with the integer.
/// In case no function is provided, it squares the integer by default.
extern "C" fn apply(process: Option<extern "C" fn(c_int) -> c_int>, int: c_int) -> c_int {
match process {
Some(f) => f(int),
None => int * int
}
}
fn main() {
unsafe {
register(Some(apply));
}
}
And the code on the C side looks like this:
void register(int (*f)(int (*)(int), int)) {
...
}
No transmute
required!
FFI and unwinding
It’s important to be mindful of unwinding when working with FFI. Most
ABI strings come in two variants, one with an -unwind
suffix and one without.
The Rust
ABI always permits unwinding, so there is no Rust-unwind
ABI.
If you expect Rust panic
s or foreign (e.g. C++) exceptions to cross an FFI
boundary, that boundary must use the appropriate -unwind
ABI string.
Conversely, if you do not expect unwinding to cross an ABI boundary, use one of
the non-unwind
ABI strings.
Note: Compiling with
panic=abort
will still causepanic!
to immediately abort the process, regardless of which ABI is specified by the function thatpanic
s.
If an unwinding operation does encounter an ABI boundary that is
not permitted to unwind, the behavior depends on the source of the unwinding
(Rust panic
or a foreign exception):
panic
will cause the process to safely abort.- A foreign exception entering Rust will cause undefined behavior.
Note that the interaction of catch_unwind
with foreign exceptions is
undefined, as is the interaction of panic
with foreign exception-catching
mechanisms (notably C++'s try
/catch
).
Rust panic
with "C-unwind"
#[no_mangle]
extern "C-unwind" fn example() {
panic!("Uh oh");
}
This function (when compiled with panic=unwind
) is permitted to unwind C++
stack frames.
[Rust function with `catch_unwind`, which stops the unwinding]
|
...
|
[C++ frames]
| ^
| (calls) | (unwinding
v | goes this
[Rust function `example`] | way)
| |
+--- rust function panics --+
If the C++ frames have objects, their destructors will be called.
C++ throw
with "C-unwind"
#[link(...)]
extern "C-unwind" {
// A C++ function that may throw an exception
fn may_throw();
}
#[no_mangle]
extern "C-unwind" fn rust_passthrough() {
let b = Box::new(5);
unsafe { may_throw(); }
println!("{:?}", &b);
}
A C++ function with a try
block may invoke rust_passthrough
and catch
an
exception thrown by may_throw
.
[C++ function with `try` block that invokes `rust_passthrough`]
|
...
|
[Rust function `rust_passthrough`]
| ^
| (calls) | (unwinding
v | goes this
[C++ function `may_throw`] | way)
| |
+--- C++ function throws ----+
If may_throw
does throw an exception, b
will be dropped. Otherwise, 5
will be printed.
panic
can be stopped at an ABI boundary
#![allow(unused)] fn main() { #[no_mangle] extern "C" fn assert_nonzero(input: u32) { assert!(input != 0) } }
If assert_nonzero
is called with the argument 0
, the runtime is guaranteed
to (safely) abort the process, whether or not compiled with panic=abort
.
Catching panic
preemptively
If you are writing Rust code that may panic, and you don't wish to abort the
process if it panics, you must use catch_unwind
:
use std::panic::catch_unwind; #[no_mangle] pub extern "C" fn oh_no() -> i32 { let result = catch_unwind(|| { panic!("Oops!"); }); match result { Ok(_) => 0, Err(_) => 1, } } fn main() {}
Please note that catch_unwind
will only catch unwinding panics, not
those that abort the process. See the documentation of catch_unwind
for more information.
Representing opaque structs
Sometimes, a C library wants to provide a pointer to something, but not let you know the internal details of the thing it wants.
A stable and simple way is to use a void *
argument:
void foo(void *arg);
void bar(void *arg);
We can represent this in Rust with the c_void
type:
extern "C" {
pub fn foo(arg: *mut libc::c_void);
pub fn bar(arg: *mut libc::c_void);
}
fn main() {}
This is a perfectly valid way of handling the situation. However, we can do a bit
better. To solve this, some C libraries will instead create a struct
, where
the details and memory layout of the struct are private. This gives some amount
of type safety. These structures are called ‘opaque’. Here’s an example, in C:
struct Foo; /* Foo is a structure, but its contents are not part of the public interface */
struct Bar;
void foo(struct Foo *arg);
void bar(struct Bar *arg);
To do this in Rust, let’s create our own opaque types:
#[repr(C)] pub struct Foo { _data: [u8; 0], _marker: core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>, } #[repr(C)] pub struct Bar { _data: [u8; 0], _marker: core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>, } extern "C" { pub fn foo(arg: *mut Foo); pub fn bar(arg: *mut Bar); } fn main() {}
By including at least one private field and no constructor,
we create an opaque type that we can't instantiate outside of this module.
(A struct with no field could be instantiated by anyone.)
We also want to use this type in FFI, so we have to add #[repr(C)]
.
The marker ensures the compiler does not mark the struct as Send
, Sync
and Unpin
are
not applied to the struct. (*mut u8
is not Send
or Sync
, PhantomPinned
is not Unpin
)
But because our Foo
and Bar
types are
different, we’ll get type safety between the two of them, so we cannot
accidentally pass a pointer to Foo
to bar()
.
Notice that it is a really bad idea to use an empty enum as FFI type.
The compiler relies on empty enums being uninhabited, so handling values of type
&Empty
is a huge footgun and can lead to buggy program behavior (by triggering
undefined behavior).
NOTE: The simplest way would use "extern types". But it's currently (as of June 2021) unstable and has some unresolved questions, see the RFC page and the tracking issue for more details.
Beneath std
This section documents features that are normally provided by the std
crate and
that #![no_std]
developers have to deal with (i.e. provide) to build
#![no_std]
binary crates.
Using libc
In order to build a #[no_std]
executable we will need libc
as a dependency.
We can specify this using our Cargo.toml
file:
[dependencies]
libc = { version = "0.2.146", default-features = false }
Note that the default features have been disabled. This is a critical step -
the default features of libc
include the std
crate and so must be
disabled.
Alternatively, we can use the unstable rustc_private
private feature together
with an extern crate libc;
declaration as shown in the examples below. Note that
windows-msvc targets do not require a libc, and correspondingly there is no libc
crate in their sysroot. We do not need the extern crate libc;
below, and having it
on a windows-msvc target would be a compile error.
Writing an executable without std
We will probably need a nightly version of the compiler to produce
a #![no_std]
executable because on many platforms, we have to provide the
eh_personality
lang item, which is unstable.
You will need to define a symbol for the entry point that is suitable for your target. For example, main
, _start
, WinMain
, or whatever starting point is relevant for your target.
Additionally, you need to use the #![no_main]
attribute to prevent the compiler from attempting to generate an entry point itself.
Additionally, it's required to define a panic handler function.
#![feature(lang_items, core_intrinsics, rustc_private)] #![allow(internal_features)] #![no_std] #![no_main] // Necessary for `panic = "unwind"` builds on cfg(unix) platforms. #![feature(panic_unwind)] extern crate unwind; // Pull in the system libc library for what crt0.o likely requires. #[cfg(not(windows))] extern crate libc; use core::ffi::{c_char, c_int}; use core::panic::PanicInfo; // Entry point for this program. #[no_mangle] // ensure that this symbol is included in the output as `main` extern "C" fn main(_argc: c_int, _argv: *const *const c_char) -> c_int { 0 } // These functions are used by the compiler, but not for an empty program like this. // They are normally provided by `std`. #[lang = "eh_personality"] fn rust_eh_personality() {} #[panic_handler] fn panic_handler(_info: &PanicInfo) -> ! { core::intrinsics::abort() }
If you are working with a target that doesn't have binary releases of the
standard library available via rustup (this probably means you are building the
core
crate yourself) and need compiler-rt intrinsics (i.e. you are probably
getting linker errors when building an executable:
undefined reference to `__aeabi_memcpy'
), you need to manually link to the
compiler_builtins
crate to get those intrinsics and solve the linker errors.
#[panic_handler]
#[panic_handler]
is used to define the behavior of panic!
in #![no_std]
applications.
The #[panic_handler]
attribute must be applied to a function with signature fn(&PanicInfo) -> !
and such function must appear once in the dependency graph of a binary / dylib / cdylib
crate. The API of PanicInfo
can be found in the API docs.
Given that #![no_std]
applications have no standard output and that some #![no_std]
applications, e.g. embedded applications, need different panicking behaviors for development and for
release it can be helpful to have panic crates, crate that only contain a #[panic_handler]
.
This way applications can easily swap the panicking behavior by simply linking to a different panic
crate.
Below is shown an example where an application has a different panicking behavior depending on
whether is compiled using the dev profile (cargo build
) or using the release profile (cargo build --release
).
panic-semihosting
crate -- log panic messages to the host stderr using semihosting:
#![no_std]
use core::fmt::{Write, self};
use core::panic::PanicInfo;
struct HStderr {
// ..
_0: (),
}
impl HStderr {
fn new() -> HStderr { HStderr { _0: () } }
}
impl fmt::Write for HStderr {
fn write_str(&mut self, _: &str) -> fmt::Result { Ok(()) }
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
let mut host_stderr = HStderr::new();
// logs "panicked at '$reason', src/main.rs:27:4" to the host stderr
writeln!(host_stderr, "{}", info).ok();
loop {}
}
panic-halt
crate -- halt the thread on panic; messages are discarded:
#![no_std]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
app
crate:
#![no_std]
// dev profile
#[cfg(debug_assertions)]
extern crate panic_semihosting;
// release profile
#[cfg(not(debug_assertions))]
extern crate panic_halt;
fn main() {
// ..
}