연산자 표현식
연산자 표현식 (Operator Expressions)
Rust의 연산자는 언어에 내장된 타입들에 대해 미리 정의되어 있어요. 이번 장에서는 대입, 비교, 비트 연산 등 각 연산자가 어떤 의미를 갖고, 오버플로우나 평가 순서 같은 예외는 어떻게 처리되는지 살펴볼게요.
출처: Rust Reference
본문
연산자는 Rust 언어가 내장 타입에 대해 정의해요. 아래에 나오는 많은 연산자는 std::ops나 std::cmp의 trait으로 오버로딩할 수도 있어요.
오버플로우 (Overflow)
정수 연산자는 디버그 모드로 컴파일했을 때 오버플로우가 나면 패닉해요. -C debug-assertions와 -C overflow-checks 컴파일러 플래그로 이를 더 직접 제어할 수 있어요. 다음 경우들이 오버플로우로 간주돼요:
+,*, 또는 이항-가 저장 가능한 최댓값보다 크거나 최솟값보다 작은 값을 만들 때.- 부호 있는 정수 타입 중 가장 음수인 값에 단항
-를 적용할 때. 단, 피연산자가 리터럴 표현식(또는 그룹 표현식 하나 이상 안에 단독으로 있는 리터럴 표현식)인 경우는 제외해요. - 좌변 인자가 부호 있는 정수 타입의 최솟값이고 우변 인자가
-1일 때/나%를 사용하는 경우. 이런 검사는 레거시 이유로-C overflow-checks가 꺼져 있어도 수행돼요. - 우변 인자가 좌변 인자의 타입 비트 수보다 크거나 같거나, 음수인 경우
<<나>>를 사용하는 것.
참고: 단항
-뒤의 리터럴 표현식에 대한 예외는-128_i8이나let j: i8 = -(128)같은 형태가 절대 패닉을 일으키지 않고 예상대로-128값을 갖는다는 뜻이에요. 이런 경우 리터럴 표현식은 이미 자신의 타입에서 가장 음수인 값을 갖고 있어요(예:128_i8은-128값). 정수 리터럴은 타입에 맞게 잘리기 때문이에요. 이원 보수 오버플로우 규약 때문에 이 값들의 부정(negation)은 값을 그대로 두어요. rustc에서는 이런 가장 음수인 표현식이overflowing_literalslint 검사에서도 무시돼요.
대여 연산자 (Borrow operators)
BorrowExpression → ( & | && ) Expression
| ( & | && ) mut Expression
| ( & | && ) raw const Expression
| ( & | && ) raw mut Expression
&(공유 대여)와 &mut(가변 대여) 연산자는 단항 접두 연산자예요. place 표현식에 적용하면 그 값이 가리키는 위치에 대한 참조(포인터)를 만들어요.
그 메모리 위치는 참조가 살아 있는 동안 대여된 상태가 돼요. 공유 대여(&)면 그 place는 변경될 수 없지만 읽거나 다시 공유할 수 있어요. 가변 대여(&mut)면 대여가 끝날 때까지 그 place를 어떤 방식으로도 접근할 수 없어요.
&mut는 피연산자를 가변 place 표현식 컨텍스트에서 평가해요.
&나 &mut를 값 표현식에 적용하면 임시 값(temporary)이 만들어져요.
이 연산자들은 오버로딩할 수 없어요.
#![allow(unused)]
fn main() {
{
// a temporary with value 7 is created that lasts for this scope.
let shared_reference = &7;
}
let mut array = [-2, 3, 9];
{
// Mutably borrows `array` for this scope.
// `array` may only be used through `mutable_reference`.
let mutable_reference = &mut array;
}
}
&&는 하나의 토큰(지연 'and' 연산자)이지만, 대여 표현식의 맥락에서는 두 개의 대여로 동작해요:
#![allow(unused)]
fn main() {
// same meanings:
let a = && 10;
let a = & & 10;
// same meanings:
let a = &&&& mut 10;
let a = && && mut 10;
let a = & & & & mut 10;
}
원시 대여 연산자 (Raw borrow operators)
&raw const와 &raw mut는 원시 대여 연산자예요.
이 연산자들의 피연산자 표현식은 place 표현식 컨텍스트에서 평가돼요.
&raw const expr는 그 place에 대한 *const T 타입의 const 원시 포인터를 만들고, &raw mut expr은 *mut T 타입의 가변 원시 포인터를 만들어요.
place 표현식이 제대로 정렬되지 않았거나 그 타입이 결정하는 유효한 값을 저장하지 않는 위치로 평가될 수 있을 때, 또는 참조를 만드는 것이 잘못된 aliasing 가정을 도입할 때는 원시 대여 연산자를 써야 해요. 그런 상황에서 대여 연산자를 쓰면 잘못된 참조를 만들어 미정의 동작(undefined behavior)이 되지만, 원시 포인터는 여전히 만들 수 있어요.
packed 구조체를 통해 정렬되지 않은 place에 원시 포인터를 만드는 예시예요:
#![allow(unused)]
fn main() {
#[repr(packed)]
struct Packed {
f1: u8,
f2: u16,
}
let packed = Packed { f1: 1, f2: 2 };
// `&packed.f2` would create an unaligned reference, and thus be undefined behavior!
let raw_f2 = &raw const packed.f2;
assert_eq!(unsafe { raw_f2.read_unaligned() }, 2);
}
유효한 값을 담고 있지 않은 place에 원시 포인터를 만드는 예시예요:
#![allow(unused)]
fn main() {
use std::mem::MaybeUninit;
struct Demo {
field: bool,
}
let mut uninit = MaybeUninit::<Demo>::uninit();
// `&uninit.as_mut().field` would create a reference to an uninitialized `bool`,
// and thus be undefined behavior!
let f1_ptr = unsafe { &raw mut (*uninit.as_mut_ptr()).field };
unsafe { f1_ptr.write(true); }
let init = unsafe { uninit.assume_init() };
}
역참조 연산자 (The dereference operator)
DereferenceExpression → * Expression
*(역참조) 연산자도 단항 접두 연산자예요. 포인터나 Box에 적용하면 가리키는 위치를 나타내요.
표현식이 &mut T, *mut T, Box<T> 타입이고, 지역 변수이거나 지역 변수의 (중첩된) 필드이거나 가변 place 표현식이라면 결과 메모리 위치에 대입할 수 있어요.
Box에 적용하면 결과 place를 이동(move)할 수 있어요.
원시 포인터의 역참조는 unsafe가 필요해요.
비포인터 타입에서 *x는 불변 place 표현식 컨텍스트에서 *std::ops::Deref::deref(&x)와 같고, 가변 place 표현식 컨텍스트에서 *std::ops::DerefMut::deref_mut(&mut x)와 같아요. 단, *x가 임시 라이프타임 연장을 겪을 때 역참조된 표현식 x의 임시 범위도 함께 연장돼요.
#![allow(unused)]
fn main() {
struct NoCopy;
let a = &7;
assert_eq!(*a, 7);
let b = &mut 9;
*b = 11;
assert_eq!(*b, 11);
let c = Box::new(NoCopy);
let d: NoCopy = *c;
}
다음 예시는 * 연산자가 임시 라이프타임을 어떻게 다루는지 보여줘요. String::new()의 결과를 담는 임시 값이 블록 끝까지 살아 있어서 x를 다음 문장에서 쓸 수 있지만, Deref::deref를 직접 호출한 경우는 문장 끝에서 임시 값이 버려져 y를 나중에 쓸 수 없어요.
#![allow(unused)]
fn main() {
// The temporary holding the result of `String::new()` is extended
// to live to the end of the block, so `x` may be used in subsequent
// statements.
let x = &*String::new();
x;
}
#![allow(unused)]
fn main() {
// The temporary holding the result of `String::new()` is dropped at
// the end of the statement, so it's an error to use `y` after.
let y = &*std::ops::Deref::deref(&String::new()); // ERROR
y;
}
try 전파 표현식 (Try propagation expression)
TryPropagationExpression → Expression ?
try 전파 표현식은 내부 표현식의 값과 Try trait을 사용해, 값을 생성할지(그렇다면 어떤 값인지), 아니면 호출자에게 값을 반환할지(그렇다면 어떤 값인지)를 결정해요.
#![allow(unused)]
fn main() {
use std::num::ParseIntError;
fn try_to_parse() -> Result<i32, ParseIntError> {
let x: i32 = "123".parse()?; // `x` is `123`.
let y: i32 = "24a".parse()?; // Returns an `Err()` immediately.
Ok(x + y) // Doesn't run.
}
let res = try_to_parse();
println!("{res:?}");
assert!(res.is_err())
}
Option에서도 같은 원리가 동작해요:
#![allow(unused)]
fn main() {
fn try_option_some() -> Option<u8> {
let val = Some(1)?;
Some(val)
}
assert_eq!(try_option_some(), Some(1));
fn try_option_none() -> Option<u8> {
let val = None?;
Some(val)
}
assert_eq!(try_option_none(), None);
}
?는 ControlFlow와 함께 쓰여 함수형 순회에서 조기 종료를 표현할 수도 있어요:
use std::ops::ControlFlow;
pub struct TreeNode<T> {
value: T,
left: Option<Box<TreeNode<T>>>,
right: Option<Box<TreeNode<T>>>,
}
impl<T> TreeNode<T> {
pub fn traverse_inorder<B>(&self, f: &mut impl FnMut(&T) -> ControlFlow<B>) -> ControlFlow<B> {
if let Some(left) = &self.left {
left.traverse_inorder(f)?;
}
f(&self.value)?;
if let Some(right) = &self.right {
right.traverse_inorder(f)?;
}
ControlFlow::Continue(())
}
}
fn main() {
let n = TreeNode {
value: 1,
left: Some(Box::new(TreeNode{value: 2, left: None, right: None})),
right: None,
};
let v = n.traverse_inorder(&mut |t| {
if *t == 2 {
ControlFlow::Break("found")
} else {
ControlFlow::Continue(())
}
});
assert_eq!(v, ControlFlow::Break("found"));
}
참고:
Trytrait은 현재 불안정(unstable)이라 사용자 타입에 구현할 수 없어요.
try 전파 표현식은 현재 대략 다음처럼 동작해요:
#![allow(unused)]
fn main() {
#![ feature(try_trait_v2) ]
fn example() -> Result<(), ()> {
let expr = Ok(());
match core::ops::Try::branch(expr) {
core::ops::ControlFlow::Continue(val) => val,
core::ops::ControlFlow::Break(residual) =>
return core::ops::FromResidual::from_residual(residual),
}
Ok(())
}
}
참고: try 전파 연산자는 때로 물음표 연산자(question mark operator),
?연산자, try 연산자라고도 불려요.
try 전파 연산자는 다음 타입들의 표현식에 적용할 수 있어요:
Result:Result::Ok(val)은val로 평가돼요.Result::Err(e)는Result::Err(From::from(e))를 반환해요.
Option:Option::Some(val)은val로 평가돼요.Option::None은Option::None을 반환해요.
ControlFlow:ControlFlow::Continue(c)는c로 평가돼요.ControlFlow::Break(b)는ControlFlow::Break(b)를 반환해요.
Poll:Poll::Ready(Ok(val))은Poll::Ready(val)로 평가돼요.Poll::Ready(Err(e))는Poll::Ready(Err(From::from(e)))를 반환해요.Poll::Pending은Poll::Pending으로 평가돼요.
Poll<Option<Result<T, E>>>:Poll::Ready(Some(Ok(val)))은Poll::Ready(Some(val))로 평가돼요.Poll::Ready(Some(Err(e)))는Poll::Ready(Some(Err(From::from(e))))를 반환해요.Poll::Ready(None)은Poll::Ready(None)으로 평가돼요.Poll::Pending은Poll::Pending으로 평가돼요.
부정 연산자 (Negation operators)
NegationExpression → - Expression
| ! Expression
이 둘이 마지막 두 개의 단항 연산자예요. 아래 표는 원시 타입에서의 동작과, 다른 타입에서 이 연산자들을 오버로딩하는 데 쓰는 trait을 요약해요. 부호 있는 정수는 항상 이원 보수(two's complement)로 표현된다는 점을 기억해 두세요. 이 연산자들의 피연산자는 모두 값 표현식 컨텍스트에서 평가되므로 이동되거나 복사돼요.
| 기호 | 정수 | bool | 부동소수점 | 오버로딩 trait |
|---|---|---|---|---|
- |
부정* | 부정 | std::ops::Neg |
|
! |
비트 NOT | 논리 NOT | std::ops::Not |
* 부호 있는 정수 타입에만 해당해요.
이 연산자들의 예시예요:
#![allow(unused)]
fn main() {
let x = 6;
assert_eq!(-x, -6);
assert_eq!(!x, -7);
assert_eq!(true, !false);
}
산술 및 논리 이항 연산자 (Arithmetic and logical binary operators)
ArithmeticOrLogicalExpression → Expression + Expression
| Expression - Expression
| Expression * Expression
| Expression / Expression
| Expression % Expression
| Expression & Expression
| Expression | Expression
| Expression ^ Expression
| Expression << Expression
| Expression >> Expression
이항 연산자 표현식은 모두 infix 표기법(피연산자 사이에 연산자)으로 써요. 아래 표는 산술·논리 이항 연산자가 원시 타입에서 어떻게 동작하는지와, 다른 타입에 오버로딩할 때 쓰는 trait을 요약해요. 부호 있는 정수는 항상 이원 보수로 표현되고, 모든 피연산자는 값 표현식 컨텍스트에서 평가되어 이동되거나 복사돼요.
| 기호 | 정수 | bool | 부동소수점 | 오버로딩 trait | 복합 대입 오버로딩 trait |
|---|---|---|---|---|---|
+ |
덧셈 | 덧셈 | std::ops::Add |
std::ops::AddAssign |
|
- |
뺄셈 | 뺄셈 | std::ops::Sub |
std::ops::SubAssign |
|
* |
곱셈 | 곱셈 | std::ops::Mul |
std::ops::MulAssign |
|
/ |
나눗셈*† | 나눗셈 | std::ops::Div |
std::ops::DivAssign |
|
% |
나머지**† | 나머지 | std::ops::Rem |
std::ops::RemAssign |
|
& |
비트 AND | 논리 AND | std::ops::BitAnd |
std::ops::BitAndAssign |
|
| |
비트 OR | 논리 OR | std::ops::BitOr |
std::ops::BitOrAssign |
|
^ |
비트 XOR | 논리 XOR | std::ops::BitXor |
std::ops::BitXorAssign |
|
<< |
왼쪽 시프트 | std::ops::Shl |
std::ops::ShlAssign |
||
>> |
오른쪽 시프트*** | std::ops::Shr |
std::ops::ShrAssign |
* 정수 나눗셈은 0 방향으로 반올림해요.
** Rust는 절단(truncating) 나눗셈으로 정의된 나머지를 사용해요. remainder = dividend % divisor라고 할 때, 나머지는 피제수(dividend)와 같은 부호를 가져요.
*** 부호 있는 정수 타입에서는 산술 오른쪽 시프트, 부호 없는 정수 타입에서는 논리 오른쪽 시프트예요.
† 정수 타입에서 0으로 나누면 패닉해요.
이 연산자들이 사용된 예시예요:
#![allow(unused)]
fn main() {
assert_eq!(3 + 6, 9);
assert_eq!(5.5 - 1.25, 4.25);
assert_eq!(-5 * 14, -70);
assert_eq!(14 / 3, 4);
assert_eq!(100 % 7, 2);
assert_eq!(0b1010 & 0b1100, 0b1000);
assert_eq!(0b1010 | 0b1100, 0b1110);
assert_eq!(0b1010 ^ 0b1100, 0b110);
assert_eq!(13 << 3, 104);
assert_eq!(-10 >> 2, -3);
}
비교 연산자 (Comparison operators)
ComparisonExpression → Expression == Expression
| Expression != Expression
| Expression > Expression
| Expression < Expression
| Expression >= Expression
| Expression <= Expression
비교 연산자도 원시 타입과 표준 라이브러리의 많은 타입에 대해 정의되어 있어요.
비교 연산자를 연쇄(chaining) 할 때는 괄호가 필요해요. 예를 들어 a == b == c는 유효하지 않고, (a == b) == c라고 써야 해요.
산술·논리 연산자와 달리, 이 연산자들을 오버로딩하는 trait은 타입이 어떻게 비교될 수 있는지를 보여주기 위해 더 일반적으로 쓰여요. 그리고 이 trait들을 바운드로 사용하는 함수들은 이 trait들이 실제 비교를 정의한다고 가정하는 경우가 많아요. 표준 라이브러리의 많은 함수와 매크로가 (안전을 보장하는 데 쓰지는 않지만) 그 가정을 활용해요.
또한 위의 산술·논리 연산자와 달리, 이 연산자들은 피연산자의 공유 대여를 암묵적으로 받아 place 표현식 컨텍스트에서 평가해요:
#![allow(unused)]
fn main() {
let a = 1;
let b = 1;
a == b;
// is equivalent to
::std::cmp::PartialEq::eq(&a, &b);
}
즉 피연산자를 이동시킬 필요가 없어요.
| 기호 | 의미 | 오버로딩 메서드 |
|---|---|---|
== |
같음 | std::cmp::PartialEq::eq |
!= |
같지 않음 | std::cmp::PartialEq::ne |
> |
큼 | std::cmp::PartialOrd::gt |
< |
작음 | std::cmp::PartialOrd::lt |
>= |
크거나 같음 | std::cmp::PartialOrd::ge |
<= |
작거나 같음 | std::cmp::PartialOrd::le |
비교 연산자의 사용 예시예요:
#![allow(unused)]
fn main() {
assert!(123 == 123);
assert!(23 != -12);
assert!(12.5 > 12.2);
assert!([1, 2, 3] < [1, 3, 4]);
assert!('A' <= 'B');
assert!("World" >= "Hello");
}
지연 불리언 연산자 (Lazy boolean operators)
LazyBooleanExpression → Expression || Expression
| Expression && Expression
||와 && 연산자는 bool 타입의 피연산자에 적용할 수 있어요. ||는 논리 'or', &&는 논리 'and'를 나타내요.
이들은 |와 &와 달리, 왼쪽 피연산자가 결과를 이미 결정하지 못했을 때만 오른쪽 피연산자를 평가해요. 즉 ||는 왼쪽이 false일 때만, &&는 왼쪽이 true일 때만 오른쪽 피연산자를 평가해요.
#![allow(unused)]
fn main() {
let x = false || true; // true
let y = false && panic!(); // false, doesn't evaluate `panic!()`
}
타입 캐스트 표현식 (Type cast expressions)
TypeCastExpression → Expression as TypeNoBounds
타입 캐스트 표현식은 이항 연산자 as로 나타내요. as 표현식을 실행하면 왼쪽의 값을 오른쪽의 타입으로 캐스트해요.
as 표현식 예시예요:
#![allow(unused)]
fn main() {
fn sum(values: &[f64]) -> f64 { 0.0 }
fn len(values: &[f64]) -> i32 { 0 }
fn average(values: &[f64]) -> f64 {
let sum: f64 = sum(values);
let size: f64 = len(values) as f64;
sum / size
}
}
as는 강제(coercion)를 명시적으로 수행하는 데 쓸 수 있고, 아래 표의 추가 캐스트도 수행해요. 강제 규칙이나 표의 항목에 맞지 않는 캐스트는 컴파일 오류예요. 여기서 *T는 *const T 또는 *mut T를 의미해요. m은 참조 타입에서 선택적인 mut, 포인터 타입에서 mut 또는 const를 의미해요.
e의 타입 |
U |
e as U가 수행하는 캐스트 |
|---|---|---|
| 정수 또는 실수 타입 | 정수 또는 실수 타입 | 숫자 캐스트 |
| 열거형 | 정수 타입 | 열거형 캐스트 |
bool 또는 char |
정수 타입 | 원시대 정수 캐스트 |
u8 |
char |
u8 대 char 캐스트 |
*T |
*V (호환될 때) |
포인터 대 포인터 캐스트 |
*T (T: Sized) |
정수 타입 | 포인터 대 주소 캐스트 |
| 정수 타입 | *V (V: Sized) |
주소 대 포인터 캐스트 |
&m₁ [T; n] |
*m₂ T ¹ |
배열 대 포인터 캐스트 |
*m₁ [T; n] |
*m₂ T ¹ |
배열 대 포인터 캐스트 |
| 함수 항목 | 함수 포인터 | 함수 항목 대 함수 포인터 캐스트 |
| 함수 항목 | *V (V: Sized) |
함수 항목 대 포인터 캐스트 |
| 함수 항목 | 정수 | 함수 항목 대 주소 캐스트 |
| 함수 포인터 | *V (V: Sized) |
함수 포인터 대 포인터 캐스트 |
| 함수 포인터 | 정수 | 함수 포인터 대 주소 캐스트 |
| 클로저 ² | 함수 포인터 | 클로저 대 함수 포인터 캐스트 |
¹ m₁이 mut이거나 m₂가 const일 때만 가능해요. 가변 참조/포인터를 const 포인터로 캐스트하는 것은 허용돼요.
² 로컬 변수를 캡처하지 않는(닫지 않는) 클로저만 함수 포인터로 캐스트할 수 있어요.
의미론 (Semantics)
숫자 캐스트 (Numeric cast)
같은 크기의 정수 사이 캐스트(예: i32 -> u32)는 no-op이에요. Rust는 고정 정수의 음수 값에 2의 보수를 사용해요:
#![allow(unused)]
fn main() {
assert_eq!(42i8 as u8, 42u8);
assert_eq!(-1i8 as u8, 255u8);
assert_eq!(255u8 as i8, -1i8);
assert_eq!(-1i16 as u16, 65535u16);
}
큰 정수에서 작은 정수로 캐스트(예: u32 -> u8)는 잘림(truncate)돼요:
#![allow(unused)]
fn main() {
assert_eq!(42u16 as u8, 42u8);
assert_eq!(1234u16 as u8, 210u8);
assert_eq!(0xabcdu16 as u8, 0xcdu8);
assert_eq!(-42i16 as i8, -42i8);
assert_eq!(1234u16 as i8, -46i8);
assert_eq!(0xabcdi32 as i8, -51i8);
}
작은 정수에서 큰 정수로 캐스트(예: u8 -> u32)는:
- 원본이 부호 없으면 zero-extend,
- 원본이 부호 있으면 sign-extend해요.
#![allow(unused)]
fn main() {
assert_eq!(42i8 as i16, 42i16);
assert_eq!(-17i8 as i16, -17i16);
assert_eq!(0b1000_1010u8 as u16, 0b0000_0000_1000_1010u16, "Zero-extend");
assert_eq!(0b0000_1010i8 as i16, 0b0000_0000_0000_1010i16, "Sign-extend 0");
assert_eq!(0b1000_1010u8 as i8 as i16, 0b1111_1111_1000_1010u16 as i16, "Sign-extend 1");
}
실수에서 정수로 캐스트는 0 방향으로 반올림해요:
- NaN은 0을 반환해요.
- 최대 정수값보다 큰 값(INFINITY 포함)은 정수 타입의 최댓값으로 포화(saturate)돼요.
- 최소 정수값보다 작은 값(NEG_INFINITY 포함)은 정수 타입의 최솟값으로 포화돼요.
#![allow(unused)]
fn main() {
assert_eq!(42.9f32 as i32, 42);
assert_eq!(-42.9f32 as i32, -42);
assert_eq!(42_000_000f32 as i32, 42_000_000);
assert_eq!(std::f32::NAN as i32, 0);
assert_eq!(1_000_000_000_000_000f32 as i32, 0x7fffffffi32);
assert_eq!(std::f32::NEG_INFINITY as i32, -0x80000000i32);
}
정수에서 실수로 캐스트는 가능한 한 가까운 실수를 만들어요*:
- 필요하면 roundTiesToEven 모드로 반올림해요***.
- 오버플로우 시 (입력과 같은 부호의) 무한대가 만들어져요.
- 참고: 현재 숫자 타입 집합에서 오버플로우는
f32::MAX + (0.5 ULP)이상의 값에 대해u128 as f32에서만 발생할 수 있어요.
#![allow(unused)]
fn main() {
assert_eq!(1337i32 as f32, 1337f32);
assert_eq!(123_456_789i32 as f32, 123_456_790f32, "Rounded");
assert_eq!(0xffffffff_ffffffff_ffffffff_ffffffff_u128 as f32, std::f32::INFINITY);
}
f32에서 f64로 캐스트는 완벽하고 손실이 없어요:
#![allow(unused)]
fn main() {
assert_eq!(1_234.5f32 as f64, 1_234.5f64);
assert_eq!(std::f32::INFINITY as f64, std::f64::INFINITY);
assert!((std::f32::NAN as f64).is_nan());
}
f64에서 f32로 캐스트는 가능한 한 가까운 f32를 만들어요**:
- 필요하면 roundTiesToEven 모드로 반올림해요***.
- 오버플로우 시 (입력과 같은 부호의) 무한대가 만들어져요.
#![allow(unused)]
fn main() {
assert_eq!(1_234.5f64 as f32, 1_234.5f32);
assert_eq!(1_234_567_891.123f64 as f32, 1_234_567_890f32, "Rounded");
assert_eq!(std::f64::INFINITY as f32, std::f32::INFINITY);
assert!((std::f64::NAN as f32).is_nan());
}
* 이런 반올림 모드와 오버플로우 동작을 가진 정수→실수 캐스트를 하드웨어가 네이티브로 지원하지 않는다면, 이 캐스트는 예상보다 느릴 수 있어요.
** 이런 반올림 모드와 오버플로우 동작을 가진 f64→f32 캐스트를 하드웨어가 네이티브로 지원하지 않는다면, 이 캐스트는 예상보다 느릴 수 있어요.
*** IEEE 754-2008 §4.3.1에서 정의한 대로: 두 실수 값 사이의 정확히 중간이면 짝수 최하위 자릿수를 가진 값을 선호하며 가장 가까운 실수를 고른다.
열거형 캐스트 (Enum cast)
열거형을 그 discriminant로 캐스트한 뒤, 필요하면 숫자 캐스트를 사용해요. 캐스트는 다음 종류의 열거형으로 제한돼요:
- 유닛 전용(unit-only) 열거형.
- 명시적 discriminant가 없는 필드 없는 열거형, 또는 유닛 변형만 명시적 discriminant를 가진 경우.
#![allow(unused)]
fn main() {
enum Enum { A, B, C }
assert_eq!(Enum::A as i32, 0);
assert_eq!(Enum::B as i32, 1);
assert_eq!(Enum::C as i32, 2);
}
열거형이 Drop을 구현하면 캐스트가 허용되지 않아요.
원시타입 대 정수 캐스트 (Primitive to integer cast)
false는 0으로,true는 1로 캐스트돼요.char는 코드 포인트의 값으로 캐스트된 뒤, 필요하면 숫자 캐스트를 사용해요.
#![allow(unused)]
fn main() {
assert_eq!(false as i32, 0);
assert_eq!(true as i32, 1);
assert_eq!('A' as i32, 65);
assert_eq!('Ö' as i32, 214);
}
u8 대 char 캐스트 (u8 to char cast)
대응하는 코드 포인트를 가진 char로 캐스트해요.
#![allow(unused)]
fn main() {
assert_eq!(65u8 as char, 'A');
assert_eq!(214u8 as char, 'Ö');
}
포인터 대 주소 캐스트 (Pointer to address cast)
원시 포인터에서 정수로 캐스트하면 참조된 메모리의 머신 주소를 만들어요. 정수 타입이 포인터 타입보다 작으면 주소가 잘릴 수 있는데, usize를 쓰면 이를 피할 수 있어요.
주소 대 포인터 캐스트 (Address to pointer cast)
정수에서 원시 포인터로 캐스트하면 정수를 메모리 주소로 해석해서 그 메모리를 참조하는 포인터를 만들어요.
경고: 이 캐스트는 아직 개발 중인 Rust 메모리 모델과 상호작용해요. 이 캐스트로 얻은 포인터는 유효한 포인터와 비트 단위로 같아도 추가 제약을 받을 수 있어요. aliasing 규칙을 지키지 않으면 그런 포인터의 역참조는 미정의 동작이 될 수 있어요.
건전한 주소 연산의 간단한 예시예요:
#![allow(unused)]
fn main() {
let mut values: [i32; 2] = [1, 2];
let p1: *mut i32 = values.as_mut_ptr();
let first_address = p1 as usize;
let second_address = first_address + 4; // 4 == size_of::<i32>()
let p2 = second_address as *mut i32;
unsafe {
*p2 += 1;
}
assert_eq!(values[1], 3);
}
포인터 대 포인터 캐스트 (Pointer-to-pointer cast)
*const T / *mut T는 다음 동작으로 *const U / *mut U에 캐스트할 수 있어요:
T와 U가 모두 sized면 포인터는 그대로 반환돼요.
#![allow(unused)]
fn main() {
let x: i32 = 42;
let p1: *const i32 = &x;
let p2: *const u8 = p1 as *const u8;
// The pointer address remains the same.
assert_eq!(p1 as usize, p2 as usize);
}
T가 unsized이고 U가 sized면 캐스트는 와이드 포인터 T를 완성하는 모든 메타데이터를 버리고, unsized 포인터의 데이터 부분으로 이루어진 얇은 포인터 U를 만들어요.
#![allow(unused)]
fn main() {
let slice: &[i32] = &[1, 2, 3];
let ptr: *const [i32] = slice as *const [i32];
// Cast from wide pointer (*const [i32]) to thin pointer (*const i32)
// discarding the length metadata.
let data_ptr: *const i32 = ptr as *const i32;
assert_eq!(unsafe { *data_ptr }, 1);
}
T와 U가 모두 unsized면 포인터도 그대로 반환돼요. 특히 메타데이터가 정확히 보존돼요. 다만 아래 규칙에 따라 메타데이터가 호환될 때만 캐스트를 수행할 수 있어요:
- T와 U가 슬라이스 메타데이터를 가진 unsized일 때는 항상 호환돼요. 슬라이스의 메타데이터는 요소의 개수이므로,
*[u16] -> *[u8]캐스트는 합법적이지만 바이트 수가 절반으로 줄어들어요.
#![allow(unused)]
fn main() {
let slice: &[u16] = &[1, 2, 3];
let ptr: *const [u16] = slice as *const [u16];
let byte_ptr: *const [u8] = ptr as *const [u8];
assert_eq!(byte_ptr.len(), 3);
}
- 주된 trait(principal trait)이 같아야 해요.
#![allow(unused)]
fn main() {
trait Foo {}
trait Bar {}
impl Foo for i32 {}
impl Bar for i32 {}
let x: i32 = 42;
let ptr_foo: *const dyn Foo = &x as *const dyn Foo;
// You can't cast to a different principal trait.
let ptr_bar: *const dyn Bar = ptr_foo as *const dyn Bar; // ERROR
}
- 자동 trait( auto trait)은 제거될 수 있어요.
#![allow(unused)]
fn main() {
trait Foo {}
struct S;
impl Foo for S {}
unsafe impl Send for S {}
let s = S;
let ptr_send: *const (dyn Foo + Send) = &s;
// Removing an auto trait.
let ptr_no_send: *const dyn Foo = ptr_send as *const dyn Foo;
}
- 자동 trait은 주된 trait의 수퍼 trait일 때만 추가할 수 있어요.
#![allow(unused)]
fn main() {
trait Foo: Send {}
struct S;
impl Foo for S {}
unsafe impl Send for S {}
let s = S;
let ptr_no_send: *const dyn Foo = &s;
// Adding an auto trait.
let ptr_send: *const (dyn Foo + Send) = ptr_no_send as *const (dyn Foo + Send);
}
#![allow(unused)]
fn main() {
trait Foo {}
struct S;
impl Foo for S {}
unsafe impl Send for S {}
let s = S;
let ptr_no_send: *const dyn Foo = &s;
// Same as above, except trait Foo does not have Send as a super trait.
let ptr_send: *const (dyn Foo + Send) = ptr_no_send as *const (dyn Foo + Send); // ERROR
}
- 뒤따르는 라이프타임은 짧아질 수만 있어요.
#![allow(unused)]
fn main() {
trait Foo {}
fn shorten_lifetime<'long: 'short, 'short>(
ptr: *const (dyn Foo + 'long),
) -> *const (dyn Foo + 'short) {
// Shortening the lifetime is allowed.
ptr as *const (dyn Foo + 'short)
}
}
#![allow(unused)]
fn main() {
trait Foo {}
fn lengthen_lifetime<'long: 'short, 'short>(
ptr: *const (dyn Foo + 'short),
) -> *const (dyn Foo + 'long) {
// It is not allowed to cast to a longer lifetime.
ptr as *const (dyn Foo + 'long) // ERROR
}
}
- 제네릭(라이프타임 포함)과 연관 타입은 정확히 일치해야 해요.
#![allow(unused)]
fn main() {
trait Generic<T> {}
impl Generic<i32> for () {}
impl Generic<u32> for () {}
let x = ();
let ptr_i32: *const dyn Generic<i32> = &x;
// You can't cast to a different generic parameter.
let ptr_u32: *const dyn Generic<u32> = ptr_i32 as *const dyn Generic<u32>; // ERROR
}
#![allow(unused)]
fn main() {
trait HasType {
type Output;
}
trait Generic<'x, T> {}
fn cast_via_associated<'a, 'b, A, B>(
ptr: *const dyn Generic<'a, A::Output>,
) -> *const dyn Generic<'b, B::Output>
where
'a: 'b,
'b: 'a,
A: HasType,
B: HasType<Output = A::Output>, // Forces equality
{
ptr as *const dyn Generic<'b, B::Output>
}
}
- T 또는 U가 마지막 필드가 unsized인 구조체나 튜플 타입일 때, 자신의 마지막 필드와 같은 메타데이터 및 호환성 규칙을 가져요.
#![allow(unused)]
fn main() {
struct Wrapper(u32, [u8]);
let slice: &[u8] = &[1, 2, 3];
let ptr: *const [u8] = slice;
// The metadata (length 3) is preserved when casting to a struct
// where the last field is the unsized type `[u8]`.
let wrapper_ptr: *const Wrapper = ptr as *const Wrapper;
// And preserved when casting back.
let ptr_back: *const [u8] = wrapper_ptr as *const [u8];
assert_eq!(ptr_back.len(), 3);
}
대입 표현식 (Assignment expressions)
AssignmentExpression → Expression = Expression
대입 표현식은 값을 지정된 place로 이동해요. 가변 assignee 표현식(대입 대상 피연산자) 뒤에 등호(=)와 값 표현식(대입되는 값 피연산자)이 온다는 구조예요.
가장 기본적인 형태에서 assignee 표현식은 place 표현식이고, 이 경우를 먼저 다뤄볼게요.
더 일반적인 비구조화 대입(destructuring assignment)은 아래에서 다루는데, 이 경우는 항상 place 표현식에 대한 순차적 대입으로 분해되며, 이것이 더 근본적인 경우로 볼 수 있어요.
기본 대입 (Basic assignments)
대입 표현식의 평가는 피연산자를 평가하는 것으로 시작해요. 대입되는 값 피연산자가 먼저 평가되고, 그다음 assignee 표현식이 평가돼요. 비구조화 대입에서는 assignee 표현식의 부분 표현식이 왼쪽에서 오른쪽으로 평가돼요.
참고: 이는 다른 표현식과 달라서, 오른쪽 피연산자가 왼쪽보다 먼저 평가돼요.
그런 다음 대입 대상 place에 있던 값을 먼저 버리고(drop), 초기화되지 않은 지역 변수나 지역 변수의 초기화되지 않은 필드라면 버리지 않아요. 다음으로 대입되는 값을 그 place로 복사하거나 이동해요.
대입 표현식은 항상 유닛 값(unit value)을 만들어요.
예시:
#![allow(unused)]
fn main() {
let mut x = 0;
let y = 0;
x = y;
}
비구조화 대입 (Destructuring assignments)
비구조화 대입은 변수 선언을 위한 비구조화 패턴 매치의 대응물로, 튜플이나 구조체 같은 복합 값에 대입을 허용해요. 예를 들어 두 가변 변수를 서로 바꿀 수 있어요:
#![allow(unused)]
fn main() {
let (mut a, mut b) = (0, 1);
// Swap `a` and `b` using destructuring assignment.
(b, a) = (a, b);
}
let을 쓰는 비구조화 선언과 달리, 구문적 모호성 때문에 대입의 왼쪽에는 패턴이 나올 수 없어요. 대신 패턴에 대응하는 표현식 그룹을 assignee 표현식으로 지정해서 대입의 왼쪽에 쓸 수 있어요. assignee 표현식은 패턴 매치 뒤에 순차적 대입이 오는 형태로 desugar돼요.
desugar된 패턴은 반드시 부정할 수 없어야(irrefutable) 해요. 특히 컴파일 타임에 길이를 아는 슬라이스 패턴과 사소한 슬라이스 [..]만 비구조화 대입에 허용돼요.
desugar 방식은 단순하고 예시로 설명하는 게 가장 좋아요.
#![allow(unused)]
fn main() {
struct Struct { x: u32, y: u32 }
let (mut a, mut b) = (0, 0);
(a, b) = (3, 4);
[a, b] = [3, 4];
Struct { x: a, y: b } = Struct { x: 3, y: 4};
// desugars to:
{
let (_a, _b) = (3, 4);
a = _a;
b = _b;
}
{
let [_a, _b] = [3, 4];
a = _a;
b = _b;
}
{
let Struct { x: _a, y: _b } = Struct { x: 3, y: 4};
a = _a;
b = _b;
}
}
식별자는 단일 assignee 표현식 안에서 여러 번 사용해도 금지되지 않아요. 밑줄 표현식과 빈 범위 표현식은 값을 바인딩하지 않고 일부 값을 무시하는 데 쓸 수 있어요.
desugar된 표현식에는 기본 바인딩 모드가 적용되지 않는다는 점에 주의해요.
참고: desugar는 비구조화 대입의 대입되는 값 피연산자(오른쪽) 의 임시 범위를 제한해요.
기본 대입에서는 임시 값이 둘러싼 임시 범위 끝에서 버려져요. 아래에서는 그게 문장이에요. 그래서 대입과 사용이 허용돼요.
#![allow(unused)]
fn main() {
fn temp() {}
fn f<T>(x: T) -> T { x }
let x;
(x = f(&temp()), x); // OK
}
반대로 비구조화 대입에서는 임시 값이 desugar의 let 문장 끝에서 버려져요. 우리가 x에 대입하려고 하기 전에 그런 일이 일어나므로 아래는 실패해요.
#![allow(unused)]
fn main() {
fn temp() {}
fn f<T>(x: T) -> T { x }
let x;
[x] = [f(&temp())]; // ERROR
}
이것은 다음과 같이 desugar돼요:
#![allow(unused)]
fn main() {
fn temp() {}
fn f<T>(x: T) -> T { x }
let x;
{
let [_x] = [f(&temp())];
// ^
// The temporary is dropped here.
x = _x; // ERROR
}
}
참고: desugar 때문에 비구조화 대입의 대입되는 값 피연산자(오른쪽)는 새로 도입된 블록 안의 확장 표현식이 돼요.
아래에서는 임시 범위가 도입된 블록 끝까지 연장되므로 대입이 허용돼요.
#![allow(unused)]
fn main() {
fn temp() {}
let x;
[x] = [&temp()]; // OK
}
이것은 다음과 같이 desugar돼요:
#![allow(unused)]
fn main() {
fn temp() {}
let x;
{ let [_x] = [&temp()]; x = _x; } // OK
}
하지만 같은 문장 안에서라도 x를 사용하려 하면, 임시 값이 이 도입된 블록 끝에서 버려지기 때문에 오류가 나요.
#![allow(unused)]
fn main() {
fn temp() {}
let x;
([x] = [&temp()], x); // ERROR
}
이것은 다음과 같이 desugar돼요:
#![allow(unused)]
fn main() {
fn temp() {}
let x;
(
{
let [_x] = [&temp()];
x = _x;
}, // <-- The temporary is dropped here.
x, // ERROR
);
}
복합 대입 표현식 (Compound assignment expressions)
CompoundAssignmentExpression → Expression += Expression
| Expression -= Expression
| Expression *= Expression
| Expression /= Expression
| Expression %= Expression
| Expression &= Expression
| Expression |= Expression
| Expression ^= Expression
| Expression <<= Expression
| Expression >>= Expression
복합 대입 표현식은 산술·논리 이항 연산자와 대입 표현식을 결합해요. 예를 들어:
#![allow(unused)]
fn main() {
let mut x = 5;
x += 1;
assert!(x == 6);
}
복합 대입의 구문은 가변 place 표현식(대입 대상 피연산자), 그다음 연산자 하나 뒤에 공백 없이 =가 붙은 단일 토큰, 그리고 값 표현식(수정 피연산자) 순서예요.
다른 place 피연산자와 달리, 대입 대상 place 피연산자는 반드시 place 표현식이어야 해요. 값 표현식을 쓰려 하면 임시로 승격하는 대신 컴파일 오류가 나요.
복합 대입 표현식의 평가는 피연산자의 타입에 따라 달라져요.
두 피연산자의 타입이 모노모화(monomorphization) 전에 원시 타입임을 알 수 있으면, 오른쪽이 먼저 평가되고 그다음 왼쪽이 평가되며, 왼쪽 평가 결과가 주는 place가 양쪽 값에 연산자를 적용해 변경돼요.
use core::{num::Wrapping, ops::AddAssign};
trait Equate {}
impl<T> Equate for (T, T) {}
fn f1(x: (u8,)) {
let mut order = vec![];
// The RHS is evaluated first as both operands are of primitive
// type.
{ order.push(2); x }.0 += { order.push(1); x }.0;
assert!(order.is_sorted());
}
fn f2(x: (Wrapping<u8>,)) {
let mut order = vec![];
// The LHS is evaluated first as `Wrapping<_>` is not a primitive
// type.
{ order.push(1); x }.0 += { order.push(2); (0u8,) }.0;
assert!(order.is_sorted());
}
fn f3<T: AddAssign<u8> + Copy>(x: (T,)) where (T, u8): Equate {
let mut order = vec![];
// The LHS is evaluated first as one of the operands is a generic
// parameter, even though that generic parameter can be unified
// with a primitive type due to the where clause bound.
{ order.push(1); x }.0 += { order.push(2); (0u8,) }.0;
assert!(order.is_sorted());
}
fn main() {
f1((0u8,));
f2((Wrapping(0u8),));
// We supply a primitive type as the generic argument, but this
// does not affect the evaluation order in `f3` when
// monomorphized.
f3::<u8>((0u8,));
}
참고: 이는 이례적이에요. 다른 곳에서는 왼쪽에서 오른쪽 평가가 표준이기 때문이에요.
그 외의 경우 이 표현식은 연산자에 해당하는 trait을 사용하고, 왼쪽을 receiver로, 오른쪽을 다음 인자로 해서 그 메서드를 호출하는 구문 설탕(syntactic sugar) 이에요. 예를 들어 다음 두 문장은 동등해요:
#![allow(unused)]
fn main() {
use std::ops::AddAssign;
fn f<T: AddAssign + Copy>(mut x: T, y: T) {
x += y; // Statement 1.
x.add_assign(y); // Statement 2.
}
}
참고: 놀랍게도, 이것을 완전히 정규화된(fully qualified) 메서드 호출로 더 desugar하는 것은 동등하지 않아요. 첫 번째 피연산자에 대한 가변 참조를 autoref로 취할 때 특별한 borrow checker 동작이 있기 때문이에요.
#![allow(unused)]
fn main() {
use std::ops::AddAssign;
fn f<T: AddAssign + Copy>(mut x: T) {
// Here we used `x` as both the LHS and the RHS. Because the
// mutable borrow of the LHS needed to call the trait method
// is taken implicitly by autoref, this is OK.
x += x; //~ OK
x.add_assign(x); //~ OK
}
}
#![allow(unused)]
fn main() {
use std::ops::AddAssign;
fn f<T: AddAssign + Copy>(mut x: T) {
// We can't desugar the above to the below, as once we take the
// mutable borrow of `x` to pass the first argument, we can't
// pass `x` by value in the second argument because the mutable
// reference is still live.
<T as AddAssign>::add_assign(&mut x, x);
//~^ ERROR cannot use `x` because it was mutably borrowed
}
}
#![allow(unused)]
fn main() {
use std::ops::AddAssign;
fn f<T: AddAssign + Copy>(mut x: T) {
// As above.
(&mut x).add_assign(x);
//~^ ERROR cannot use `x` because it was mutably borrowed
}
}
일반 대입 표현식과 마찬가지로 복합 대입 표현식도 항상 유닛 값을 만들어요.
경고: 복합 대입의 피연산자 평가 순서에 의존하는 코드는 이례적이고 놀라울 수 있으니 피하는 게 좋아요.
더 알아보기 (Learn more)
- 연산자 오버로딩에 사용되는 trait들은
std::ops와std::cmp모듈에서 확인할 수 있어요. ?연산자와Result/Option의 조합은 실무에서 가장 흔하게 쓰는 패턴이니, 에러 처리와 함께 공부하면 좋아요.