연산자 표현식

연산자 표현식 (Operator Expressions)

Rust의 연산자는 언어에 내장된 타입들에 대해 미리 정의되어 있어요. 이번 장에서는 대입, 비교, 비트 연산 등 각 연산자가 어떤 의미를 갖고, 오버플로우나 평가 순서 같은 예외는 어떻게 처리되는지 살펴볼게요.

출처: Rust Reference

본문

연산자는 Rust 언어가 내장 타입에 대해 정의해요. 아래에 나오는 많은 연산자는 std::opsstd::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_literals lint 검사에서도 무시돼요.

대여 연산자 (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"));
}

참고: Try trait은 현재 불안정(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::NoneOption::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::PendingPoll::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::PendingPoll::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 u8char 캐스트
*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::opsstd::cmp 모듈에서 확인할 수 있어요.
  • ? 연산자와 Result/Option의 조합은 실무에서 가장 흔하게 쓰는 패턴이니, 에러 처리와 함께 공부하면 좋아요.