안전하지 않은 러스트
지금까지 이 책에서 다뤄 온 모든 코드는 컴파일 시점에 러스트의 메모리 안전성 보장을 받았습니다. 하지만 러스트 안에는 이런 메모리 안전성 보장을 강제하지 않는, 또 다른 언어가 숨어 있습니다. 그것이 바로 unsafe Rust 입니다. unsafe Rust는 일반 러스트와 똑같이 생겼지만, 몇 가지 “추가 능력”을 제공합니다.
Unsafe Rust가 존재하는 이유는, 정적 분석이 본질적으로 보수적이기 때문입니다. 컴파일러가 코드가 안전성 보장을 지키는지 판단하려 할 때, 어떤 잘못된 프로그램을 받아들이는 것보다는 어떤 올바른 프로그램을 거부하는 편이 더 낫습니다. 코드가 실제로는 괜찮을 수도 있지만, 컴파일러가 확신할 만큼 충분한 정보를 갖고 있지 않다면 거부합니다. 이런 경우 unsafe 코드를 사용하면 컴파일러에게 “나를 믿어라, 내가 뭘 하는지 안다”라고 말할 수 있습니다. 하지만 분명히 해두자면, unsafe Rust는 여러분 책임 하에 사용하는 것입니다. 잘못 사용하면 null 포인터 역참조 같은 메모리 안전성 문제가 실제로 발생할 수 있습니다.
Unsafe Rust가 존재하는 또 다른 이유는, 컴퓨터 하드웨어 자체가 본질적으로 안전하지 않기 때문입니다. 만약 러스트가 unsafe 작업을 전혀 허용하지 않았다면, 저수준 시스템 프로그래밍, 예를 들어 운영체제와 직접 상호작용하거나 심지어 운영체제 자체를 작성하는 일을 할 수 없었을 것입니다. 이런 저수준 시스템 프로그래밍은 러스트가 목표로 하는 용도 중 하나입니다. 이제 unsafe Rust로 무엇을 할 수 있는지, 그리고 어떻게 하는지 살펴봅시다.
unsafe 초능력 사용하기
Unsafe Rust로 전환하려면 unsafe 키워드를 쓴 뒤, 그 안에 unsafe 코드를 담은 새
블록을 시작하면 됩니다. Unsafe Rust 안에서는 안전한 러스트에서는 할 수 없는 다섯
가지 동작을 할 수 있는데, 우리는 이것을 unsafe 초능력 이라고 부르겠습니다.
- raw 포인터를 역참조하기
- unsafe 함수나 메서드 호출하기
- 가변 static 변수에 접근하거나 수정하기
- unsafe 트레이트 구현하기
union의 필드에 접근하기
중요한 점은, unsafe 가 대여 검사기를 끄거나 러스트의 다른 안전성 검사를 전부
비활성화하는 것이 아니라는 사실입니다. unsafe 코드 안에서 참조를 사용하면,
그 참조는 여전히 정상적으로 검사됩니다. unsafe 키워드는 컴파일러가 메모리
안전성까지 검사해 주지 않는 이 다섯 가지 기능에만 접근 권한을 주는 것입니다.
즉 unsafe 블록 안에도 여전히 꽤 많은 안전성이 남아 있습니다.
또한 unsafe 라고 해서 그 블록 안 코드가 반드시 위험하거나 메모리 안전성 문제가
있다는 뜻도 아닙니다. 의미는 오히려 반대입니다. 그 블록 안의 코드가 메모리를 유효한
방식으로 접근하도록 프로그래머가 직접 책임지겠다 는 것입니다.
사람은 실수할 수 있고, 실제로 실수는 일어납니다. 하지만 이런 다섯 가지 unsafe 연산이
반드시 unsafe 라고 표시된 블록 안에 들어가게 만들면, 메모리 안전성과 관련된 오류가
발생했을 때 그 원인을 unsafe 블록 안에서 찾으면 된다는 큰 힌트를 얻게 됩니다. unsafe
블록은 가능한 한 작게 유지하세요. 나중에 메모리 버그를 추적할 때 분명 고맙게 느끼게
될 것입니다.
unsafe 코드를 최대한 격리하기 위해, 보통은 unsafe 코드를 안전한 추상화 안에 감싸고
안전한 API를 제공하는 것이 좋습니다. 이 장 뒤에서 unsafe 함수와 메서드를 다룰 때
그 예를 보겠습니다. 표준 라이브러리의 일부도 이미 이런 방식으로 구현되어 있습니다.
검증된 unsafe 코드를 안전한 추상화 안에 감싸 두면, 여러분이나 라이브러리 사용자가
그 기능을 쓰는 코드 곳곳으로 unsafe 가 새어 나오지 않게 할 수 있습니다. 안전한
추상화 자체는 그대로 안전하니까요.
이제 다섯 가지 unsafe 초능력을 하나씩 살펴보겠습니다. 또한 unsafe 코드에 대해 안전한 인터페이스를 제공하는 추상화도 함께 보겠습니다.
raw 포인터 역참조하기
4장의 [“댕글링 참조”][dangling-references] 절에서, 컴파일러가
참조가 언제나 유효함을 보장한다고 했습니다. Unsafe Rust에는 참조와 비슷하지만
새로운 타입 둘이 있습니다. 바로 raw 포인터 입니다. 참조와 마찬가지로 raw 포인터도
불변과 가변이 있으며, 각각 *const T, *mut T 로 씁니다. 여기서 별표는 역참조
연산자가 아니라 타입 이름의 일부입니다. raw 포인터에서 불변 이라는 것은 “포인터가
가리키는 값을 직접 바꿀 수 없다”는 뜻입니다.
일반 참조나 스마트 포인터와 달리, raw 포인터는 다음과 같은 특징을 가집니다.
- 같은 위치에 대해 불변 포인터와 가변 포인터를 동시에 두거나, 가변 포인터 여러 개를 둘 수 있어 대여 규칙을 무시할 수 있다
- 유효한 메모리를 가리킨다는 보장이 없다
- null 일 수 있다
- 자동 정리 같은 기능을 전혀 제공하지 않는다
이런 보장을 포기하는 대신, 여러분은 더 높은 성능이나 외부 언어/하드웨어와의 상호작용 능력을 얻습니다. 그런 영역에서는 러스트의 일반 보장이 그대로 적용되지 않기 때문입니다.
목록 20-1은 raw borrow 연산자를 사용해 불변 raw 포인터와 가변 raw 포인터를 만드는 예를 보여 줍니다.
fn main() {
let mut num = 5;
let r1 = &raw const num;
let r2 = &raw mut num;
}
이 코드 자체에는 unsafe 키워드가 없다는 점에 주목하세요. raw 포인터를 만드는 것
자체는 안전한 코드에서도 할 수 있고, 진짜 위험한 일은 그것을 역참조해 값을 읽는
것입니다. 그때는 곧 보게 될 것처럼 unsafe 블록이 필요합니다.
또한 raw borrow 연산자 대신 as 캐스팅을 사용해, 유효할지 확신할 수 없는 raw
포인터도 만들 수 있습니다. 목록 20-2는 임의의 메모리 위치를 가리키는 raw 포인터를
만드는 예입니다. 이런 임의 메모리 사용은 정의되지 않은 동작이며, 그 주소에 데이터가
있을 수도, 없을 수도 있고, 컴파일러가 아예 메모리 접근을 최적화로 없애 버릴 수도
있으며, 프로그램이 segmentation fault 로 끝날 수도 있습니다. 대개 raw borrow
연산자로 충분하므로 이런 코드를 직접 작성할 좋은 이유는 거의 없지만, 기술적으로는
가능합니다.
fn main() {
let address = 0x012345usize;
let r = address as *const i32;
}
앞서 말했듯 raw 포인터를 만드는 것 은 안전한 코드에서도 가능합니다. 하지만
그 포인터를 역참조해 가리키는 데이터를 읽는 것은 unsafe 입니다. 목록 20-3에서는
raw 포인터에 * 역참조 연산자를 적용하는 코드가 반드시 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);
}
}
포인터를 만드는 것 자체는 아무 해도 끼치지 않습니다. 위험은 그 포인터가 가리키는 값을 읽거나 쓸 때, 그 값이 유효한지 아닌지를 우리가 보장해야 한다는 데 있습니다.
또한 목록 20-1과 20-3에서는 *const i32, *mut i32 raw 포인터를 둘 다 같은
메모리 위치 num 에 대해 만들었다는 점도 주목하세요. 만약 이것을 불변 참조와 가변
참조로 만들려 했다면, 러스트의 소유권 규칙 때문에 컴파일되지 않았을 것입니다.
러스트는 가변 참조가 있는 동안 불변 참조를 허용하지 않기 때문입니다. 하지만 raw
포인터는 같은 위치에 대해 불변과 가변 포인터를 동시에 만들 수도 있고, 가변 포인터를
통해 데이터를 바꿀 수도 있습니다. 따라서 데이터 경쟁을 직접 만들어 낼 가능성도
생깁니다. 조심해야 합니다!
그렇다면 왜 이런 위험한 raw 포인터를 굳이 쓰고 싶을까요? 대표적인 이유는 외부 언어와 상호작용할 때입니다. 다음 절에서 C 코드와 연동하는 예를 보겠습니다. 또 다른 이유는 대여 검사기가 이해하지 못하는 안전한 추상화를 직접 만들어야 할 때입니다. 이제 unsafe 함수와 메서드를 소개하고, unsafe 코드를 감싼 안전한 추상화의 예도 함께 보겠습니다.
unsafe 함수 또는 메서드 호출하기
unsafe 블록 안에서 할 수 있는 두 번째 일은 unsafe 함수 호출입니다. unsafe 함수와
메서드는 겉보기에는 일반 함수와 똑같지만, 정의 앞에 unsafe 키워드가 하나 더 붙어
있습니다. 이 문맥에서 unsafe 키워드는, 이 함수를 호출할 때 지켜야 할 어떤 전제
조건이 있고, 러스트는 우리가 그 조건을 지켰는지 보장할 수 없다는 뜻입니다. 따라서
unsafe 함수를 unsafe 블록 안에서 호출한다는 것은, “이 함수 문서를 읽었고,
올바른 사용법을 이해했으며, 그 계약을 우리가 직접 만족시킨다”고 선언하는 것입니다.
본문이 텅 비어 있는 dangerous 라는 unsafe 함수를 예로 들어 봅시다.
fn main() {
unsafe fn dangerous() {}
unsafe {
dangerous();
}
}
우리는 이 dangerous 함수를 반드시 별도의 unsafe 블록 안에서 호출해야 합니다.
그렇지 않으면 오류가 납니다.
$ cargo run
Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
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`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error
unsafe 블록을 씀으로써 우리는 “이 함수 문서를 읽었고, 안전하게 사용하는 방법을
이해했으며, 함수가 요구하는 계약을 직접 지키고 있다”고 러스트에게 알려 주는 것입니다.
중요한 점은, unsafe 함수 본문 안에서 unsafe 연산을 하려 해도 여전히 unsafe 블록이
또 필요하다는 것입니다. 일반 함수 안에서와 마찬가지입니다. 따라서 컴파일러는 함수
전체에 무분별하게 unsafe 가 퍼지지 않도록 도와 줍니다.
unsafe 코드에 대한 안전한 추상화 만들기
함수 안에 unsafe 코드가 들어 있다고 해서, 함수 전체 를 unsafe 로 만들 필요는
없습니다. 실제로 unsafe 코드를 안전한 함수 안에 감싸는 것은 매우 흔한 추상화
기법입니다. 예시로, 표준 라이브러리의 split_at_mut 함수를 직접 어떻게 구현할 수
있을지 살펴보겠습니다. 이 메서드는 일부 unsafe 코드가 필요하지만, 외부에는 안전한
함수로 노출됩니다. split_at_mut 은 가변 슬라이스 위에 정의된 안전한 메서드로,
슬라이스와 인덱스 하나를 받아 슬라이스를 두 개로 나눕니다. 목록 20-4는 이 안전한
메서드를 사용하는 예입니다.
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 함수 사용하기이 함수를 오직 안전한 러스트만으로 구현하려 하면, 목록 20-5처럼 되겠지만 컴파일되지
않습니다. 단순화를 위해 이 예제에서는 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);
}
split_at_mut 을 구현하려는 시도이 함수는 먼저 슬라이스 전체 길이를 구합니다. 그런 뒤 mid 인덱스가 슬라이스 길이
이하인지 assert! 로 검사합니다. 따라서 너무 큰 인덱스를 넘기면 잘못된 슬라이스를
만들기 전에 바로 패닉하게 됩니다.
그다음 하나는 원래 슬라이스 시작부터 mid 까지, 다른 하나는 mid 부터 끝까지인
두 개의 가변 슬라이스를 튜플로 반환하려 합니다.
하지만 이 코드를 컴파일하면 다음과 같은 오류가 납니다.
$ cargo run
Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
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`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error
러스트의 대여 검사기는 우리가 “슬라이스의 서로 다른 두 부분”을 빌리려는 것임을 이해하지 못합니다. 그저 하나의 같은 슬라이스를 두 번 가변으로 빌린 것으로만 보기 때문입니다. 실제로는 두 슬라이스가 겹치지 않으므로 이런 빌림은 안전하지만, 컴파일러는 그 정도까지 똑똑하지는 않습니다. 이럴 때가 바로 unsafe 코드를 사용할 시점입니다.
목록 20-6은 unsafe 블록, raw 포인터, 그리고 몇몇 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);
}
split_at_mut 구현 안에서 unsafe 코드 사용하기