안전하지 않은 러스트
unsafe의 원시 포인터·외부 함수·가변 정적 변수·트레이트·유니온 기능을 구분하고 안전한 추상화로 위험 범위를 제한합니다.
지금까지 러스트 프로그래밍 언어에서 가장 흔히 쓰이는 부분을 배웠습니다. 19장의 프로젝트로 가기 전에, 가끔 만날 수 있지만 매일 쓰지는 않을 언어의 몇 가지 면을 봅니다. 이번 장은 모르는 것을 만났을 때 참고 자료로 쓸 수 있습니다. 여기서 다루는 기능은 아주 특정한 상황에서만 쓸모가 있습니다. 자주 쓰지 않더라도, 러스트가 제공하는 모든 기능을 알아 두면 좋습니다.
| 글 | 다루는 것 |
|---|---|
| 이 글 | 러스트의 일부 보장을 포기하고 그 보장을 직접 지키는 방법 |
| 고급 트레이트 | 연관 타입, 기본 타입 매개변수, 완전 정규화 문법, 슈퍼트레이트, 트레이트와 관련된 뉴타입 패턴 |
| 고급 타입 | 뉴타입 패턴의 다른 쓰임, 타입 별칭, never 타입, 동적 크기 타입 |
| 고급 함수와 클로저 | 함수 포인터와 클로저 반환하기 |
| 매크로 | 컴파일 시점에 더 많은 코드를 정의하는 코드를 정의하는 방법 |
러스트의 기능을 모두 모았습니다. 시작합니다.
지금까지 다룬 코드는 모두 컴파일 시점에 러스트의 메모리 안전성 보장이 적용되었습니다. 하지만 러스트 안에는 이 메모리 안전성 보장을 적용하지 않는 두 번째 언어가 숨어 있습니다. 이 언어는 안전하지 않은 러스트(unsafe Rust)라고 불리며, 보통의 러스트와 똑같이 동작하지만 추가로 초능력을 줍니다.
안전하지 않은 러스트가 있는 까닭은, 정적 분석이 본래 보수적이기 때문입니다. 컴파일러가 코드가 보장을 지키는지 판단하려 할 때, 올바르지 않은 프로그램을 받아들이는 것보다 올바른 프로그램을 거부하는 편이 낫습니다. 코드가 괜찮을 수도 있지만, 러스트 컴파일러가 확신할 만큼의 정보가 없으면 코드를 거부합니다. 이런 경우 안전하지 않은 코드로 컴파일러에게 "믿어, 내가 뭘 하는지 알아"라고 말할 수 있습니다. 다만 경고하자면, 안전하지 않은 러스트를 쓰면 그 결과도 직접 감당해야 합니다. 안전하지 않은 코드를 잘못 쓰면 널 포인터 역참조 같은 메모리 안전하지 않은 문제가 생길 수 있습니다.
러스트에 안전하지 않은 다른 자아가 있는 또 다른 까닭은, 그 바탕이 되는 컴퓨터 하드웨어가 본래 안전하지 않기 때문입니다. 러스트가 안전하지 않은 연산을 허용하지 않는다면 어떤 작업은 할 수 없습니다. 러스트는 운영체제와 직접 상호작용하거나 직접 운영체제를 쓰는 것 같은 저수준 시스템 프로그래밍을 할 수 있어야 합니다. 저수준 시스템 프로그래밍이 이 언어의 목표 가운데 하나입니다. 안전하지 않은 러스트로 무엇을 어떻게 할 수 있는지 봅니다.
안전하지 않은 슈퍼파워
안전하지 않은 러스트로 바꾸려면 unsafe 키워드를 쓰고, 안전하지 않은 코드를 담은 새 블록을 시작합니다. 안전하지 않은 러스트에서는 안전한 러스트에서 할 수 없는 다섯 가지 일을 할 수 있으며, 이것을 안전하지 않은 초능력(unsafe superpowers)이라고 부릅니다.
| 초능력 |
|---|
| 원시 포인터(raw pointer)를 역참조합니다. |
| 안전하지 않은 함수나 메서드를 부릅니다. |
| 가변 정적 변수에 접근하거나 그것을 바꿉니다. |
| 안전하지 않은 트레이트를 구현합니다. |
union의 필드에 접근합니다. |
unsafe가 대여 검사기를 끄거나 러스트의 다른 안전성 검사를 끄지는 않는다는 점을 이해하는 것이 중요합니다. 안전하지 않은 코드에서 참조를 쓰면 여전히 검사됩니다. unsafe 키워드는 이 다섯 기능에 대한 접근만 주며, 이 기능들은 컴파일러가 메모리 안전성을 확인하지 않습니다. 안전하지 않은 블록 안에서도 어느 정도의 안전성은 여전히 얻습니다.
또 unsafe는 블록 안의 코드가 반드시 위험하거나 메모리 안전성 문제가 꼭 있다는 뜻이 아닙니다. 그 의도는, 프로그래머가 unsafe 블록 안의 코드가 메모리에 올바른 방식으로 접근하도록 보장한다는 것입니다.
사람은 실수하므로 실수는 일어나지만, 이 다섯 안전하지 않은 연산이 unsafe로 표시된 블록 안에 있어야 한다고 요구하면, 메모리 안전성과 관련된 오류는 반드시 unsafe 블록 안에 있다는 것을 알게 됩니다. unsafe 블록은 작게 유지하세요. 나중에 메모리 버그를 조사할 때 고마워할 것입니다. 아래 그림은 안전하지 않은 코드를 안전한 API로 감쌀 때 누가 무엇을 보장해야 하는지를 정리한 것입니다.
RUST · ARCHITECTURE
안전한 API도 내부 unsafe의 조건을 증명해야 한다
unsafe는 검사를 끄는 스위치가 아니라 증명 책임의 경계입니다. Rust 2024에서는 unsafe extern, #[unsafe(no_mangle)], unsafe fn 안의 명시적 unsafe 블록을 구분합니다.
Safe API 계약
호출자는 safe wrapper만 부르고, wrapper 구현은 길이·정렬·별칭·유효성 같은 안전 조건을 보장해야 합니다. 타입에서 이미 보장되는 조건과 실행 중 검사할 조건을 구분합니다.
최소 unsafe core
Book의 핵심 다섯 작업은 원시 포인터 역참조, unsafe 함수 호출, 가변 static 접근, unsafe trait 구현, union 필드 읽기입니다. borrow checker 자체가 꺼지는 것은 아닙니다.
Rust 2024 표기
unsafe extern, #[unsafe(no_mangle)], unsafe fn 안의 명시적 unsafe { ... } 블록을 각각 구분합니다.
외부 검증 경계
FFI라면 선언한 함수 시그니처와 ABI가 실제 외부 코드와 맞아야 합니다. 포인터를 만든 것만으로 역참조할 메모리의 유효성이 보장되지는 않습니다.
본문 예제 · split_at_mut(3)
let (a, b) = r.split_at_mut(3);a · [1, 2, 3]
원래 슬라이스의 0..3 범위
b · [4, 5, 6]
원래 슬라이스의 3..6 범위
유효한 &mut [i32]에서 포인터를 얻고 mid ≤ len을 검사합니다. 구현자는 두 가변 슬라이스의 범위가 겹치지 않으며 수명·정렬·유효 메모리 조건이 유지됨을 보장합니다.
- 안전 API가 검증하고 문서화한 계약
- 최소 unsafe core의 증명 의무
- ABI·메모리의 별도 검증 경계
검증된 wrapper가 최소 unsafe core와 외부 FFI를 감쌉니다. Rust 2024의 unsafe extern·#[unsafe(no_mangle)]·명시적 unsafe 블록은 서로 다른 계약을 드러냅니다.
안전한 쪽의 호출자는 안전한 러스트만 쓰며 안전한 래퍼를 부르고, 래퍼의 구현은 길이, 정렬, 별칭, 값의 유효성 같은 안전 조건을 직접 보장해야 합니다. 타입이 이미 보장하는 조건과 실행 중에 검사할 조건을 나눠 생각합니다. 래퍼 안의 최소한의 unsafe 코드에서는 다섯 초능력을 쓰되, 대여 검사기가 꺼지지는 않습니다. FFI처럼 외부와 이어진다면, 선언한 함수 시그니처와 ABI가 실제 외부 코드와 맞아야 합니다. Rust 2024 에디션에서는 이런 약속을 나타내는 표기로 unsafe extern, #[unsafe(no_mangle)], 그리고 안전하지 않은 함수 안의 명시적인 unsafe { ... } 블록을 따로 구분합니다. 래퍼는 이 조건들을 만족하는 결과만 돌려주어야 합니다. unsafe는 검사를 끄는 스위치가 아니라, 이 조건을 증명하는 일이 어디서부터 프로그래머에게 넘어오는지를 표시합니다.
안전하지 않은 코드를 최대한 격리하려면, 그런 코드를 안전한 추상화 안에 넣고 안전한 API를 제공하는 것이 가장 좋습니다. 이것은 이번 장에서 안전하지 않은 함수와 메서드를 살펴볼 때 이야기합니다. 표준 라이브러리의 일부는 감사(audit)를 받은 안전하지 않은 코드 위에 안전한 추상화로 구현되어 있습니다. 안전하지 않은 코드를 안전한 추상화로 감싸면, unsafe를 쓰는 것이 여러분이나 사용자가 unsafe 코드로 구현된 기능을 쓰고 싶어 하는 모든 곳으로 새어 나가지 않습니다. 안전한 추상화를 쓰는 것은 안전하기 때문입니다.
다섯 초능력을 하나씩 봅니다. 안전하지 않은 코드에 안전한 인터페이스를 제공하는 추상화도 봅니다.
원시 포인터 역참조하기
참조와 대여에서 컴파일러가 참조가 언제나 유효하도록 보장한다고 했습니다. 안전하지 않은 러스트에는 참조와 비슷한 원시 포인터(raw pointer)라는 두 새 타입이 있습니다. 참조처럼 원시 포인터도 불변이거나 가변일 수 있으며, 각각 *const T와 *mut T라고 씁니다. 별표는 역참조 연산자가 아니라 타입 이름의 일부입니다. 원시 포인터의 맥락에서 불변이란, 역참조한 뒤 포인터에 직접 대입할 수 없다는 뜻입니다.
참조나 스마트 포인터와 달리 원시 포인터는 다음과 같습니다.
| 원시 포인터의 특성 |
|---|
| 불변 포인터와 가변 포인터를 여럿 갖거나, 같은 위치를 가리키는 가변 포인터를 여럿 가져 대여 규칙을 무시할 수 있습니다. |
| 유효한 메모리를 가리킨다는 보장이 없습니다. |
| 널(null)일 수 있습니다. |
| 자동으로 정리되지 않습니다. |
러스트가 이런 보장을 하지 않도록 고르면, 보장된 안전성을 포기하는 대신 더 좋은 성능이나 러스트의 보장이 적용되지 않는 다른 언어나 하드웨어와 상호작용하는 능력을 얻을 수 있습니다.
다음 코드는 불변 원시 포인터와 가변 원시 포인터를 만드는 방법을 보여 줍니다.
fn main() {
let mut num = 5;
let r1 = &raw const num;
let r2 = &raw mut num;
}이 코드에는 unsafe 키워드를 넣지 않았다는 점을 보세요. 원시 포인터는 안전한 코드에서 만들 수 있습니다. 곧 보겠지만, 안전하지 않은 블록 밖에서 원시 포인터를 역참조할 수 없을 뿐입니다.
원시 대여(raw borrow) 연산자로 원시 포인터를 만들었습니다. &raw const num은 *const i32 불변 원시 포인터를, &raw mut num은 *mut i32 가변 원시 포인터를 만듭니다. 지역 변수에서 직접 만들었으므로, 이 원시 포인터들이 유효하다는 것을 압니다. 하지만 모든 원시 포인터에 대해 그렇게 가정할 수는 없습니다.
이것을 보여 주기 위해, 다음으로 원시 대여 연산자 대신 as로 값을 캐스팅해 유효한지 확신할 수 없는 원시 포인터를 만듭니다. 다음 코드는 메모리의 임의의 위치를 가리키는 원시 포인터를 만드는 방법을 보여 줍니다. 임의의 메모리를 쓰려 하는 것은 정의되지 않은 동작입니다. 그 주소에 데이터가 있을 수도 없을 수도 있고, 컴파일러가 메모리에 접근하지 않도록 코드를 최적화할 수도 있고, 프로그램이 세그멘테이션 오류(segmentation fault)로 끝날 수도 있습니다. 보통 이런 코드를 쓸 좋은 까닭은 없으며, 특히 원시 대여 연산자를 쓸 수 있는 경우에는 더욱 그렇지만, 쓸 수는 있습니다.
fn main() {
let address = 0x012345usize;
let r = address as *const i32;
}원시 포인터는 안전한 코드에서 만들 수 있지만, 원시 포인터를 역참조해 가리키는 데이터를 읽을 수는 없습니다. 다음 코드에서는 unsafe 블록이 필요한 원시 포인터에 역참조 연산자 *를 씁니다.
fn main() {
let mut num = 5;
let r1 = &raw const num;
let r2 = &raw mut num;
unsafe {
println!("r1 is: {}", *r1);
println!("r2 is: {}", *r2);
}
}포인터를 만드는 것은 해롭지 않습니다. 가리키는 값에 접근하려 할 때만, 유효하지 않은 값을 다루게 될 수 있습니다.
앞의 코드에서 같은 메모리 위치를 가리키는 *const i32와 *mut i32 원시 포인터를 만들었다는 점도 보세요. 그 위치에는 num이 저장되어 있습니다. 대신 num의 불변 참조와 가변 참조를 만들려 했다면, 러스트의 소유권 규칙이 불변 참조가 있는 동안 가변 참조를 허용하지 않으므로 코드가 컴파일되지 않았을 것입니다. 원시 포인터로는 같은 위치를 가리키는 가변 포인터와 불변 포인터를 만들고 가변 포인터로 데이터를 바꿀 수 있으며, 그러면 데이터 경쟁이 생길 수 있습니다. 조심하세요.
이런 위험이 있는데 왜 원시 포인터를 쓸까요? 주요 쓰임 하나는 다음 절에서 볼 C 코드와의 상호작용입니다. 또 다른 경우는 대여 검사기가 이해하지 못하는 안전한 추상화를 만들 때입니다. 안전하지 않은 함수를 소개한 뒤, 안전하지 않은 코드를 쓰는 안전한 추상화의 예를 봅니다.
안전하지 않은 함수 또는 메서드 호출하기
안전하지 않은 블록에서 할 수 있는 두 번째 연산은 안전하지 않은 함수를 부르는 것입니다. 안전하지 않은 함수와 메서드는 보통의 함수와 메서드와 똑같이 생겼지만, 정의의 나머지 앞에 unsafe가 더 붙어 있습니다. 이 맥락에서 unsafe 키워드는, 이 함수를 부를 때 지켜야 하는 요구 사항이 있다는 것을 나타냅니다. 러스트는 우리가 그 요구 사항을 지켰다고 보장할 수 없기 때문입니다. 안전하지 않은 함수를 unsafe 블록 안에서 부르면, 이 함수의 문서를 읽었고 함수의 요구 사항을 직접 지키겠다고 말하는 것입니다.
다음은 본문에서 아무것도 하지 않는 dangerous라는 안전하지 않은 함수입니다.
unsafe fn dangerous() {}
fn main() {
unsafe {
dangerous();
}
}dangerous 함수는 따로 된 unsafe 블록 안에서 불러야 합니다. unsafe 블록 없이 dangerous를 부르려 하면 오류가 납니다.
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block
--> src/main.rs:4:5
|
4 | dangerous();
| ^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
For more information about this error, try `rustc --explain E0133`.unsafe 블록으로 러스트에게 함수의 문서를 읽었고, 함수를 올바르게 쓰는 방법을 이해하며, 함수의 요구 사항을 지켰는지 확인했다고 단언합니다.
안전하지 않은 함수의 본문에서 안전하지 않은 연산을 하려면, 일반 함수에서처럼 여전히 unsafe 블록을 써야 하며, 잊으면 컴파일러가 경고합니다. 이렇게 하면 unsafe 블록을 작게 유지하는 데 도움이 됩니다. 안전하지 않은 연산이 함수 본문 전체에 필요하지는 않을 수 있기 때문입니다.
안전하지 않은 코드를 감싸는 안전한 추상화 만들기
함수에 안전하지 않은 코드가 들어 있다고 해서 함수 전체를 안전하지 않다고 표시해야 하는 것은 아닙니다. 사실 안전하지 않은 코드를 안전한 함수로 감싸는 것은 흔한 추상화입니다. 예로 표준 라이브러리의 split_at_mut 함수를 봅니다. 이 함수에는 안전하지 않은 코드가 필요합니다. 어떻게 구현할 수 있는지 살펴봅니다. 이 안전한 메서드는 가변 슬라이스에 정의되어 있습니다. 슬라이스 하나를 받아, 인수로 받은 인덱스에서 나눠 둘로 만듭니다. 다음 코드는 split_at_mut를 쓰는 방법을 보여 줍니다.
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];
let r = &mut v[..];
let (a, b) = r.split_at_mut(3);
assert_eq!(a, &mut [1, 2, 3]);
assert_eq!(b, &mut [4, 5, 6]);
}이 함수는 안전한 러스트만으로는 구현할 수 없습니다. 시도하면 다음 코드처럼 될 텐데, 이 코드는 컴파일되지 않습니다. 단순하게 하려고 split_at_mut를 메서드가 아니라 함수로, 제네릭 타입 T가 아니라 i32 값의 슬라이스에 대해서만 구현합니다.
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
assert!(mid <= len);
(&mut values[..mid], &mut values[mid..])
}
fn main() {
let mut vector = vec![1, 2, 3, 4, 5, 6];
let (left, right) = split_at_mut(&mut vector, 3);
}이 함수는 먼저 슬라이스의 전체 길이를 얻습니다. 그다음 매개변수로 받은 인덱스가 길이보다 작거나 같은지 확인해, 슬라이스 안에 있다고 단언합니다. 이 단언은, 슬라이스를 나눌 인덱스보다 큰 인덱스를 넘기면 그 인덱스를 쓰려 하기 전에 함수가 패닉을 낸다는 뜻입니다.
그다음 가변 슬라이스 두 개를 튜플로 돌려줍니다. 하나는 원래 슬라이스의 시작부터 mid 인덱스까지, 다른 하나는 mid부터 슬라이스의 끝까지입니다. 이 코드를 컴파일하려 하면 오류가 납니다.
error[E0499]: cannot borrow `*values` as mutable more than once at a time
--> src/main.rs:6:31
|
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
| - let's call the lifetime of this reference `'1`
...
6 | (&mut values[..mid], &mut values[mid..])
| --------------------------^^^^^^--------
| | | |
| | | second mutable borrow occurs here
| | first mutable borrow occurs here
| returning this value requires that `*values` is borrowed for `'1`
|
= help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices
For more information about this error, try `rustc --explain E0499`.러스트의 대여 검사기는 우리가 슬라이스의 서로 다른 부분을 빌리고 있다는 것을 이해하지 못합니다. 같은 슬라이스를 두 번 빌린다는 것만 압니다. 두 슬라이스가 겹치지 않으므로 슬라이스의 서로 다른 부분을 빌리는 것은 근본적으로 괜찮지만, 러스트는 그것을 알 만큼 똑똑하지 않습니다. 코드가 괜찮다는 것을 우리는 알지만 러스트는 모를 때, 안전하지 않은 코드를 쓸 때입니다.
다음 코드는 unsafe 블록과 원시 포인터, 안전하지 않은 함수 몇 번을 불러 split_at_mut의 구현이 동작하게 하는 방법을 보여 줍니다.
use std::slice;
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
let ptr = values.as_mut_ptr();
assert!(mid <= len);
unsafe {
(
slice::from_raw_parts_mut(ptr, mid),
slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
fn main() {
let mut vector = vec![1, 2, 3, 4, 5, 6];
let (left, right) = split_at_mut(&mut vector, 3);
}슬라이스에서, 슬라이스는 어떤 데이터를 가리키는 포인터와 슬라이스의 길이라고 했습니다. 슬라이스의 길이는 len 메서드로, 슬라이스의 원시 포인터는 as_mut_ptr 메서드로 얻습니다. 이 경우 i32 값의 가변 슬라이스가 있으므로, as_mut_ptr는 *mut i32 타입의 원시 포인터를 돌려주며, 그것을 변수 ptr에 저장했습니다.
mid 인덱스가 슬라이스 안에 있다는 단언은 그대로 둡니다. 그다음 안전하지 않은 코드로 갑니다. slice::from_raw_parts_mut 함수는 원시 포인터와 길이를 받아 슬라이스를 만듭니다. 이 함수로 ptr에서 시작해 원소 mid개 길이의 슬라이스를 만듭니다. 그다음 ptr에 mid를 인수로 add 메서드를 불러 mid에서 시작하는 원시 포인터를 얻고, 그 포인터와 mid 뒤에 남은 원소의 수를 길이로 써서 슬라이스를 만듭니다.
slice::from_raw_parts_mut 함수는 원시 포인터를 받고 이 포인터가 유효하다고 믿어야 하므로 안전하지 않습니다. 원시 포인터의 add 메서드도, 오프셋 위치가 유효한 포인터라고 믿어야 하므로 안전하지 않습니다. 그래서 slice::from_raw_parts_mut와 add를 부르는 곳을 unsafe 블록으로 감싸야 했습니다. 코드를 보고 mid가 len보다 작거나 같아야 한다는 단언을 더했으므로, unsafe 블록 안에서 쓰는 원시 포인터들이 모두 슬라이스 안의 데이터를 가리키는 유효한 포인터라고 말할 수 있습니다. 이것은 unsafe를 받아들일 수 있고 알맞게 쓴 예입니다.
결과로 만든 split_at_mut 함수는 unsafe로 표시하지 않아도 되며, 안전한 러스트에서 이 함수를 부를 수 있다는 점을 보세요. 이 함수는 접근하는 데이터에서 유효한 포인터만 만드는 구현으로, 안전하지 않은 코드에 대한 안전한 추상화를 만들었습니다. 안전하지 않은 코드를 안전한 방식으로 쓰기 때문입니다.
반대로 다음 코드에서 slice::from_raw_parts_mut를 쓰면, 슬라이스를 쓸 때 프로그램이 멈출 가능성이 큽니다. 이 코드는 임의의 메모리 위치를 받아 원소 10,000개 길이의 슬라이스를 만듭니다.
fn main() {
use std::slice;
let address = 0x01234usize;
let r = address as *mut i32;
let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}이 임의의 위치의 메모리를 소유하지 않으며, 이 코드가 만드는 슬라이스에 유효한 i32 값이 들어 있다는 보장도 없습니다. values를 유효한 슬라이스인 것처럼 쓰려고 하면 정의되지 않은 동작이 됩니다.
extern 함수를 사용하여 외부 코드 호출하기
러스트 코드가 다른 언어로 쓰인 코드와 상호작용해야 할 때가 있습니다. 그래서 러스트에는 외부 함수 인터페이스(Foreign Function Interface, FFI)를 만들고 쓰는 것을 쉽게 해 주는 extern 키워드가 있습니다. FFI는 프로그래밍 언어가 함수를 정의하고, 다른 프로그래밍 언어가 그 함수를 부를 수 있게 하는 방법입니다.
다음 코드는 C 표준 라이브러리의 abs 함수와의 연동을 설정하는 방법을 보여 줍니다. unsafe extern 블록 안에서 선언한 함수는 보통 러스트 코드에서 부르기에 안전하지 않으므로, unsafe extern 블록에도 unsafe를 붙여야 합니다. 다른 언어는 러스트의 규칙과 보장을 강제하지 않고 러스트는 그것을 확인할 수 없으므로, 안전성은 프로그래머가 직접 보장해야 합니다.
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}unsafe extern "C" 블록 안에는 부르고 싶은 다른 언어의 외부 함수의 이름과 시그니처를 나열합니다. "C" 부분은 외부 함수가 쓰는 애플리케이션 이진 인터페이스(application binary interface, ABI)를 정합니다. ABI는 어셈블리 수준에서 함수를 부르는 방법을 정의합니다. "C" ABI가 가장 흔하며, C 프로그래밍 언어의 ABI를 따릅니다. 러스트가 지원하는 모든 ABI는 Rust Reference에 있습니다.
unsafe extern 블록 안에서 선언한 모든 아이템은 암묵적으로 안전하지 않습니다. 하지만 어떤 FFI 함수는 부르기에 안전합니다. 예를 들어 C 표준 라이브러리의 abs 함수에는 메모리 안전성에 관한 고려 사항이 없으며, 어떤 i32로 불러도 괜찮다는 것을 압니다. 이런 경우에는 safe 키워드로, 이 특정 함수는 unsafe extern 블록 안에 있더라도 부르기에 안전하다고 말할 수 있습니다. 그렇게 바꾸면 다음 코드처럼 부를 때 더 이상 unsafe 블록이 필요 없습니다.
unsafe extern "C" {
safe fn abs(input: i32) -> i32;
}
fn main() {
println!("Absolute value of -3 according to C: {}", abs(-3));
}함수를 safe로 표시한다고 그 함수가 본래 안전해지는 것은 아닙니다. 대신 러스트에게 그 함수가 안전하다고 약속하는 것과 같습니다. 그 약속을 지키는지는 여전히 우리가 확인해야 합니다.
extern으로 다른 언어가 러스트 함수를 부르는 인터페이스를 만들 수도 있습니다. 전체 extern 블록을 만드는 대신, 관련된 함수의 fn 키워드 바로 앞에 extern 키워드와 쓸 ABI를 더합니다. 또 러스트 컴파일러에게 이 함수의 이름을 뒤섞지(mangle) 말라고 알리는 #[unsafe(no_mangle)] 속성도 더해야 합니다. 이름 뒤섞기는 컴파일러가 함수에 준 이름을, 컴파일 과정의 다른 부분이 쓸 정보를 더 담은 다른 이름으로 바꾸는 것으로, 사람이 읽기는 더 어려워집니다. 모든 프로그래밍 언어의 컴파일러는 이름을 조금씩 다르게 뒤섞으므로, 러스트 함수를 다른 언어에서 이름으로 부르려면 러스트 컴파일러의 이름 뒤섞기를 꺼야 합니다. 이름 뒤섞기 없이는 라이브러리 사이에 이름이 충돌할 수 있으므로 안전하지 않으며, 내보내는 이름이 뒤섞지 않고 내보내도 안전한지는 우리가 확인해야 합니다.
다음 예에서는 call_from_c 함수를 C 코드에서 쓸 수 있게 만듭니다. 공유 라이브러리로 컴파일하고 C에서 링크한 뒤 쓰는 것입니다.
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
println!("Just called a Rust function from C!");
}이런 extern의 쓰임에는 unsafe 블록이 아니라 속성에만 unsafe가 필요합니다.
가변 정적 변수의 접근 혹은 수정하기
이 교재에서는 아직 전역 변수를 이야기하지 않았습니다. 러스트는 전역 변수를 지원하지만, 러스트의 소유권 규칙에 문제가 될 수 있습니다. 두 스레드가 같은 가변 전역 변수에 접근하면 데이터 경쟁이 생길 수 있습니다.
러스트에서 전역 변수는 정적(static) 변수라고 부릅니다. 다음 코드는 문자열 슬라이스를 값으로 가진 정적 변수의 선언과 쓰임의 예입니다.
static HELLO_WORLD: &str = "Hello, world!";
fn main() {
println!("value is: {HELLO_WORLD}");
}정적 변수는 변수와 가변성에서 이야기한 상수와 비슷합니다. 정적 변수의 이름은 관례상 SCREAMING_SNAKE_CASE로 씁니다. 정적 변수는 'static 라이프타임을 가진 참조만 저장할 수 있으며, 그래서 러스트 컴파일러가 라이프타임을 알아낼 수 있으므로 명시적으로 적을 필요가 없습니다. 불변 정적 변수에 접근하는 것은 안전합니다.
상수와 불변 정적 변수의 미묘한 차이는, 정적 변수의 값은 메모리에서 고정된 주소를 가진다는 것입니다. 그 값을 쓰면 언제나 같은 데이터에 접근합니다. 반면 상수는 쓰일 때마다 데이터를 복제해도 됩니다. 또 다른 차이는 정적 변수는 가변일 수 있다는 것입니다. 가변 정적 변수에 접근하고 그것을 바꾸는 것은 안전하지 않습니다. 다음 코드는 COUNTER라는 가변 정적 변수를 선언하고, 접근하고, 바꾸는 방법을 보여 줍니다.
static mut COUNTER: u32 = 0;
/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
unsafe {
COUNTER += inc;
}
}
fn main() {
unsafe {
// SAFETY: This is only called from a single thread in `main`.
add_to_count(3);
println!("COUNTER: {}", *(&raw const COUNTER));
}
}보통의 변수처럼 mut 키워드로 가변성을 지정합니다. COUNTER를 읽거나 쓰는 코드는 모두 unsafe 블록 안에 있어야 합니다. 이 코드는 단일 스레드이므로 우리가 예상하는 대로 컴파일되고 COUNTER: 3을 출력합니다. 여러 스레드가 COUNTER에 접근하면 데이터 경쟁이 생길 가능성이 크므로, 그것은 정의되지 않은 동작입니다. 그래서 함수 전체를 unsafe로 표시하고, 함수를 부르는 사람이 안전하게 부르려면 무엇을 해야 하는지 알 수 있도록 안전성의 한계를 문서로 적어야 합니다.
안전하지 않은 함수를 쓸 때는, SAFETY로 시작하는 주석을 써서 호출하는 쪽이 함수를 안전하게 부르려면 무엇을 해야 하는지 설명하는 것이 관례입니다. 마찬가지로 안전하지 않은 연산을 할 때마다, SAFETY로 시작하는 주석으로 안전성 규칙을 어떻게 지키는지 설명하는 것이 관례입니다.
또 컴파일러는 기본으로 가변 정적 변수의 참조를 만드는 것을 허용하지 않습니다. 원시 대여 연산자로 만든 원시 포인터를 통해서만 접근해야 합니다. 여기서 &raw const COUNTER로 원시 포인터를 만든 까닭이 그것입니다. 이 제한에는, 참조가 보이지 않게 만들어지는 경우까지 포함됩니다. 이 코드 예제의 println!처럼 참조가 암묵적으로 만들어질 때도 그렇습니다. 가변 정적 변수의 참조를 원시 포인터를 통해서만 만들게 요구하면, 그것을 쓸 때의 안전성 요구 사항이 더 분명해집니다.
전역으로 접근할 수 있는 가변 데이터는 데이터 경쟁이 없도록 보장하기 어려우며, 그래서 러스트는 가변 정적 변수를 안전하지 않은 것으로 여깁니다. 가능하다면 스레드 동시 실행부터 이어지는 15장에서 다룬 동시성 기법과 스레드 안전한 스마트 포인터를 쓰는 편이 낫습니다. 그러면 컴파일러가 서로 다른 스레드에서의 데이터 접근이 안전하게 이루어지는지 확인해 줍니다.
안전하지 않은 트레이트 구현하기
unsafe로 안전하지 않은 트레이트를 구현할 수 있습니다. 트레이트의 메서드 가운데 적어도 하나에 컴파일러가 확인할 수 없는 불변 조건이 있으면, 그 트레이트는 안전하지 않습니다. 다음 코드처럼 trait 앞에 unsafe 키워드를 붙여 트레이트가 안전하지 않다고 선언하고, 트레이트의 구현도 unsafe로 표시합니다.
unsafe trait Foo {
// 메서드는 여기에
}
unsafe impl Foo for i32 {
// 메서드 구현은 여기에
}
fn main() {}unsafe impl을 쓰면, 컴파일러가 확인할 수 없는 불변 조건을 지키겠다고 약속하는 것입니다.
예로 Sync와 Send 트레이트를 이용한 확장 가능한 동시성에서 이야기한 Sync와 Send 마커 트레이트를 떠올려 보세요. 타입이 전부 Send와 Sync를 구현하는 다른 타입으로 이루어졌다면, 컴파일러는 이 트레이트들을 자동으로 구현합니다. 원시 포인터처럼 Send나 Sync를 구현하지 않는 타입이 들어 있는 타입을 Send나 Sync로 표시하고 싶다면, unsafe를 써야 합니다. 러스트는 우리 타입이 스레드 사이로 안전하게 보내지거나 여러 스레드에서 접근될 수 있다는 보장을 지키는지 확인할 수 없습니다. 그러므로 그 확인을 직접 하고 unsafe로 나타내야 합니다.
유니온 필드에 접근하기
unsafe로만 할 수 있는 마지막 동작은 유니온(union)의 필드에 접근하는 것입니다. 유니온은 구조체와 비슷하지만, 한 인스턴스에서 한 번에 하나의 필드만 선언된 것으로 쓰입니다. 유니온은 주로 C 코드의 유니온과 상호작용하는 데 쓰입니다. 유니온 필드에 접근하는 것은 안전하지 않습니다. 러스트가 지금 유니온 인스턴스에 어떤 타입의 데이터가 저장되어 있는지 보장할 수 없기 때문입니다. 유니온은 Rust Reference에서 더 배울 수 있습니다.
Miri로 안전하지 않은 코드 검사하기
안전하지 않은 코드를 쓸 때, 쓴 것이 실제로 안전하고 올바른지 확인하고 싶을 것입니다. 가장 좋은 방법 가운데 하나는 정의되지 않은 동작을 찾는 공식 러스트 도구인 Miri를 쓰는 것입니다. 대여 검사기는 컴파일 시점에 동작하는 정적 도구이지만, Miri는 런타임에 동작하는 동적 도구입니다. Miri는 프로그램이나 테스트 모음을 실행하며 코드를 검사하고, 러스트가 어떻게 동작해야 하는지에 관한 규칙을 어기는 것을 찾아냅니다.
Miri를 쓰려면 러스트의 나이틀리(nightly) 빌드가 필요합니다. rustup +nightly component add miri로 나이틀리 판의 러스트와 Miri 도구를 설치할 수 있습니다. 이 명령은 프로젝트가 쓰는 러스트의 판을 바꾸지 않고, 시스템에 도구를 더해 원할 때 쓸 수 있게 할 뿐입니다. 프로젝트에서 cargo +nightly miri run이나 cargo +nightly miri test를 실행해 Miri를 쓸 수 있습니다.
rustup +nightly component add miri
cargo +nightly miri run앞에서 임의의 주소로 원소 10,000개의 슬라이스를 만든 코드에 Miri를 실행하면, 다음과 같은 결과가 나옵니다. 보통의 실행은 이런 잘못이 있어도 오류 없이 끝날 수 있지만, Miri는 코드를 해석하며 실행하므로 규칙을 어긴 그 순간에 멈추고 위치를 알려 줍니다.
warning: integer-to-pointer cast
--> src/main.rs:5:13
|
5 | let r = address as *mut i32;
| ^^^^^^^^^^^^^^^^^^^ integer-to-pointer cast
|
--생략--
error: Undefined Behavior: constructing invalid value of type &mut [i32]: encountered a dangling reference (0x1234[noalloc] has no provenance)
--> src/main.rs:7:35
|
7 | let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
|
= help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
= help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information경고의 도움말 부분은 생략했습니다. Miri는 정수를 포인터로 캐스팅했으니 포인터 버그를 놓칠 수 있다고 경고하고, 그다음 슬라이스를 만드는 곳에서 정의되지 않은 동작이 일어났다고 정확히 알려 줍니다.
Miri가 모든 것을 잡지는 못합니다. Miri는 동적 분석 도구이므로, 실제로 실행되는 코드의 문제만 잡습니다. 그러므로 안전하지 않은 코드에 대한 확신을 높이려면 좋은 테스트 기법과 함께 써야 합니다. 또 Miri는 코드가 잘못될 수 있는 모든 가능한 방법을 다루지는 않습니다. Miri가 문제를 잡았다면 버그가 있다는 것을 알지만, Miri가 버그를 잡지 못했다고 문제가 없다는 뜻은 아닙니다. 그래도 많은 것을 잡을 수 있습니다. 이번 장의 다른 안전하지 않은 코드 예제에 Miri를 실행해 무엇이라고 하는지 보세요.
unsafe 코드를 사용하는 경우
방금 이야기한 다섯 초능력 가운데 하나를 쓰려고 unsafe를 쓰는 것은 잘못되거나 눈살을 찌푸릴 일이 아니지만, 컴파일러가 메모리 안전성을 지키는 것을 도울 수 없으므로 unsafe 코드를 올바르게 쓰기는 더 까다롭습니다. unsafe 코드를 쓸 까닭이 있다면 쓸 수 있으며, 명시적인 unsafe 표시 덕분에 문제가 생겼을 때 그 원인을 추적하기가 쉬워집니다. 안전하지 않은 코드를 쓸 때마다 Miri로 코드가 러스트의 규칙을 지키는지 더 확신할 수 있습니다.
안전하지 않은 러스트를 효과적으로 다루는 방법을 더 깊이 알고 싶다면, 이 주제에 관한 러스트의 공식 안내서인 The Rustonomicon을 읽어 보세요.
더 깊이: 정의되지 않은 동작에 무엇이 들어가고 무엇이 빠지나
unsafe 블록 안에서 지켜야 할 것이 무엇인지는 Reference의 정의되지 않은 동작 목록이 정합니다. Reference는 러스트 코드가 이 목록의 동작을 하나라도 하면 올바르지 않은 코드이며, 이것은 unsafe 블록과 unsafe 함수 안의 코드에도 똑같이 적용된다고 말합니다. unsafe는 정의되지 않은 동작을 피하는 일을 프로그래머가 맡게 할 뿐, 러스트 프로그램이 정의되지 않은 동작을 결코 일으켜서는 안 된다는 사실을 바꾸지 않는다는 것입니다. 목록은 완전하지 않으며 늘거나 줄 수 있다고도 밝힙니다.
목록에는 데이터 경쟁, 매달린 포인터나 정렬이 맞지 않는 포인터로 메모리를 읽고 쓰는 것, 별칭 규칙을 어기는 것, 불변인 바이트를 바꾸는 것, 잘못된 ABI로 함수를 부르는 것, 올바르지 않은 값을 만드는 것 등이 들어 있습니다. 별칭 규칙은 아직 정확히 정해지지 않았지만, &T가 살아 있는 동안 가리키는 메모리가 UnsafeCell 안이 아닌 한 바뀌지 않아야 하고, &mut T가 살아 있는 동안 그 참조에서 나오지 않은 다른 포인터가 그 메모리를 읽거나 쓰지 않아야 한다는 원칙이 적혀 있습니다. 올바르지 않은 값의 예로는 0이나 1이 아닌 bool, 서로게이트 범위의 char, 널이거나 매달린 참조가 있습니다.
반대로 unsafe로 보지 않는 동작도 따로 정해져 있습니다. 교착 상태, 메모리와 자원의 누수, 소멸자를 부르지 않고 끝나는 것, 정수 오버플로, 그리고 HashMap의 키를 맵 안에서 바꾸는 것 같은 논리 오류는 바람직하지 않아도 정의되지 않은 동작은 아닙니다. 그래서 안전한 러스트만으로도 이런 버그는 생길 수 있으며, 안전한 러스트가 막아 주는 것은 앞의 목록에 든 동작입니다.
정리
unsafe블록은 원시 포인터 역참조, 안전하지 않은 함수 호출, 가변 정적 변수 접근, 안전하지 않은 트레이트 구현, 유니온 필드 접근의 다섯 가지만 허용하며, 대여 검사기를 끄지 않습니다.- 원시 포인터
*const T와*mut T는&raw const와&raw mut로 안전한 코드에서 만들 수 있지만, 역참조는unsafe블록 안에서만 할 수 있습니다. - 안전하지 않은 함수는
unsafe블록 안에서 불러야 하며(E0133),split_at_mut처럼 안전 조건을 직접 확인한 뒤 안전한 함수로 감싸면 바깥에서는 안전하게 쓸 수 있습니다. - 다른 언어의 함수는
unsafe extern "C"블록에 선언하고, 안전함을 아는 함수에는safe를 붙입니다. 러스트 함수를 내보낼 때는#[unsafe(no_mangle)]과extern "C"를 씁니다. - 가변 정적 변수는
unsafe블록에서만 다루고&raw const처럼 원시 포인터로 접근하며, 안전하지 않은 코드에는SAFETY주석으로 지켜야 할 조건을 적고 Miri로 검사합니다.
연습 문제
1. 무엇에 unsafe가 필요할까
다음 가운데 unsafe 블록이 필요한 것은 무엇인가요? (가) let p = &raw const x; (나) let v = *p; (다) 불변 static NAME: &str을 읽기 (라) static mut COUNT: u32에 1 더하기 (마) safe fn abs로 선언한 외부 함수 부르기.
해설 보기
(나)와 (라)에 필요합니다. 원시 포인터를 만드는 (가)는 안전한 코드에서 할 수 있고, 역참조하는 (나)에서만 unsafe가 필요합니다. 불변 정적 변수를 읽는 (다)는 안전하지만, 가변 정적 변수를 바꾸는 (라)는 데이터 경쟁의 위험이 있어 안전하지 않습니다. safe로 선언한 외부 함수를 부르는 (마)는 unsafe 블록 없이 부를 수 있으며, 그 함수가 정말 안전한지는 선언한 사람이 보장해야 합니다.
2. 안전한 래퍼의 조건
이 글의 split_at_mut에서 assert!(mid <= len); 줄을 지우면 무엇이 문제가 되나요? 이 함수를 여전히 unsafe 없이 공개해도 될까요?
해설 보기
mid가 len보다 크면 첫 번째 from_raw_parts_mut(ptr, mid)부터 원래 슬라이스 바깥까지 이어지는 슬라이스를 만들고 ptr.add(mid)도 슬라이스 바깥을 가리키므로, 그 자리에서 이미 정의되지 않은 동작이 생깁니다. 그다음 len - mid는 오버플로 검사가 켜진 기본 디버그 빌드에서는 패닉을 내고, 검사가 꺼진 릴리스 빌드에서는 감싸져 아주 큰 길이가 되어 두 번째 슬라이스도 유효하지 않은 메모리를 가리키게 됩니다. 안전한 코드가 잘못된 인수만 넘겨도 정의되지 않은 동작이 생길 수 있으므로, 단언을 지운 채로는 안전한 함수로 공개하면 안 됩니다. 안전한 래퍼는 안쪽의 unsafe 코드가 기대는 조건을 모든 입력에 대해 스스로 확인해야 합니다.
다음 글 고급 트레이트에서는 연관 타입, 기본 제네릭 타입 매개변수, 완전 정규화 문법 같은 트레이트의 고급 기능을 살펴봅니다.