[Rust] Rust가 NaN을 다루는 방법

undefcat·2024년 10월 17일
0

rust

목록 보기
7/7
post-thumbnail

연산자 오버로딩

Rust는 trait를 통해 일종의 연산자 오버로딩이 가능합니다. 예를 들어, +를 오버로딩 하고자 한다면 다음과 같이 std::ops::Add 를 원하는 타입에 구현하면 됩니다.

use std::ops::Add;

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

// Point { x: 4, y: 6 }
let result = Point { x: 1, y: 2 } + Point { x: 3, y: 4 };

마찬가지로 비교연산자들 역시 오버로딩이 가능합니다. 그런데 Rust에서 비교연산자를 구현하기 위한 trait 들을 찾다보면 PartialEq, PartialOrdEq, Ord로 나뉘어져있는 것을 알 수 있습니다. 이 둘의 차이점은 무엇이며, 왜 이렇게 나뉘어져 있는 걸까요?

부동소수점과 Not a Number

자바스크립트에서는 Number.isNaN을 사용하여 해당 값이 NaN인지 아닌지를 확인할 수 있습니다. 하지만 더 쉬운 방법이 있는데요. 바로 NaN은 자기 자신과 비교했을 때 false를 리턴하는 유일한 값임을 이용하는 방법입니다.

const nan = NaN;

nan === nan; // false

즉, 어떤 값이 NaN인지를 확인하려고 if (value === NaN)과 같이 사용한다면 이는 영원히 실행되지 않는 조건문이 됩니다. 이는 마치 SQL에서 NULL 확인을 위해 WHERE col = NULL과 같이 사용하는 경우와 같다고 볼 수 있습니다.

생각해보면 말이 안되는 것 같습니다. 자기 자신은 항상 자기 자신과 같아야 하는데, NaN은 전혀 그렇지 않습니다. 하지만 여기에는 타당한 이유가 있으며, 심지어 컴퓨터 세계에서 물리적으로도 그럴 수 밖에 없는 이유가 있습니다.

IEEE 754 에서의 NaN

IEEE 754는 부동소수점을 정의하는 표준입니다. 많은 하드웨어들과 프로그래밍 언어들이 이를 통해 부동소수점을 정의합니다. Rust 역시 예외가 아닙니다.

NaN 역시 이 IEEE 754 에서 정의하고 있으며 0/0과 같이 수학에서 숫자라고 보지 않는 값, 즉 정말로 말 그대로 Not a Number에 해당하는 개념을 정의합니다.

IEEE 754는 부동소수점 레이아웃에서 지수부분에 해당하는 부분의 비트가 모두 1인 경우를 NaN으로 정의하고 있는데, 이는 달리 말하면 32비트의 경우 지수부 8비트를 제외한 나머지 24비트가 어떤 값이든 모두 NaN이라는 의미입니다. 즉, 어떻게 보면 NaN이라는 값은 32비트에선 2^24 개가 존재한다고 볼 수 있습니다. 이렇게만 봐도 NaN은 다 같은 NaN이 아니라는 것을 알 수 있습니다.

또한 수학적으로도, 정의되지 않은 숫자(0/0)를 정의된 숫자와 비교한다는 것은 말이 되지 않습니다. 따라서 NaN과의 비교들이 false를 리턴하는 것은 매우 합리적이라고 볼 수 있습니다.

즉, NaN은 비교가 정의되지 않는 값입니다. 아니, 값이라고 할 수도 없겠죠. 애초에 존재하지 않는 경우이니까요. 컴퓨터과학에서 NaN과의 비교는 항상 false를 리턴하지만, 엄밀히 말하자면 애초에 비교를 정의할 수 없음이 더 이치에 맞다고 볼 수 있습니다.

Rust는 이를 좀 더 엄밀하게 정의하기 위해서 PartialEq, PartialOrd trait를 정의합니다. "엄밀한" 비교는 불가능하지만, "부분적" 비교는 가능하다는 의미입니다.

PartialEq, Eq

PartialEq는 비교가 부분적으로는 가능하다는 의미를 담은 trait입니다. 정의는 다음과 같습니다.

pub trait PartialEq<Rhs: ?Sized = Self> {
    fn eq(&self, other: &Rhs) -> bool;

    fn ne(&self, other: &Rhs) -> bool {
        !self.eq(other)
    }
}

여기에서 eq== 연산자로, ne!= 연산자로 오버로딩됩니다. 이 때, eq만이 구현 대상이 되는 메서드입니다. neeq를 부정한 결과를 리턴할 뿐입니다.

Rust에서 기본으로 제공하는 대부분의 타입들은 PartialEq를 구현하고 있습니다. 숫자와 관련된 타입들 역시 모두 PartialEq를 구현하고 있습니다. Eq 역시 마찬가지입니다. Rust에서 기본으로 제공하는 대부분의 타입들은 Eq를 구현하고 있습니다. 단, 부동소수점에 해당하는 f32f64Eq를 구현하고 있지 않습니다. 이는 위에서 언급했던대로, 부동소수점 세계에 존재하는 NaN은 비교가 불가능한 값이기 때문입니다.

Rust에서 Eq는 수학적으로 반사성(x == x), 대칭성(x == y -> y == x), 추이성(x == y && y == z -> x == z)을 가지는 값들에 대해서만 정의하도록 하고 있습니다. 하지만 NaN의 경우 이를 만족하지 않습니다. 그렇기에 NaN을 포함하고 있는 f32f64Eq를 구현하고 있지 않는 것입니다.

그렇다면 Eq의 정의는 어떨까요? 사실 EqPartialEq를 상속받는 trait입니다. 따로 추가적으로 구현해야 할 것은 없습니다. 즉, 일종의 marker trait입니다.

pub trait Eq: PartialEq<Self> {}

개발자는 위에서 언급한대로 반사성, 대칭성, 추이성을 만족하는 PartialEq 구현체에 대해서만 Eq를 구현해야만 합니다. 물론 Rust에서는 대부분의 경우 PartialEqEq를 직접 구현할 일이 많지는 않습니다. 예를 들어 위에서 정의한 Point 타입은 다음과 같이 derive attribute를 통해 컴파일러에게 구현을 위임할 수 있습니다.

#[derive(PartialEq, Eq)]
struct Point {
    x: i32,
    y: i32,
}

이는 Point의 필드들이 모두 PartialEqEq를 구현하고 있기 때문에 가능합니다. 만약 xyf32라면 우린 Eq를 구현할 수 없습니다.

// Trait `Eq` is not implemented for `f32` [E0277]
#[derive(PartialEq, Eq)]
struct Point {
    x: f32,
    y: f32,
}

또한 Eq만을 구현할 수는 없습니다. EqPartialEq를 super trait으로 갖기 때문에 PartialEq를 먼저 구현해야만 합니다.

// Trait `PartialEq` is not implemented for `Point` [E0277]
#[derive(Eq)]
struct Point {
	x: i32,
	y: i32,
}

PartialOrd, Ord

PartialOrdPartialEq와 마찬가지로 비교가 부분적으로는 가능하다는 의미를 담은 trait입니다. 정의는 다음과 같습니다.

pub trait PartialOrd<Rhs: ?Sized = Self>: PartialEq<Rhs> {
    fn partial_cmp(&self, other: &Rhs) -> Option<Ordering>;

    fn lt(&self, other: &Rhs) -> bool {
        matches!(self.partial_cmp(other), Some(Less))
    }

    fn le(&self, other: &Rhs) -> bool {
        matches!(self.partial_cmp(other), Some(Less | Equal))
    }

    fn gt(&self, other: &Rhs) -> bool {
        matches!(self.partial_cmp(other), Some(Greater))
    }

    fn ge(&self, other: &Rhs) -> bool {
        matches!(self.partial_cmp(other), Some(Greater | Equal))
    }
}

PartialEqeq 메서드는 반드시 구현해야만 하는 메서드인 것처럼, PartialOrdpartial_cmp 메서드도 반드시 구현해야만 하는 메서드입니다.

PartialEqneeq로부터 비롯된다면, lt, le, gt, ge 메서드들은 모두 partial_cmp로부터 비롯됩니다. 이들 메서드들이 각각 <, <=, >, >= 연산자들로 오버로딩 되는 메서드들입니다.

그런데, PartialEqeqPartialOrdpartial_cmp의 리턴값에는 차이가 있습니다. eq의 경우 순수하게 bool을 리턴하지만 partial_cmp의 경우 Option<Ordering>을 리턴합니다. 즉, 값이 존재하지 않는다는 의미인 None을 리턴할 수도 있다는 뜻입니다.

ParitalNaN으로부터 비롯됐고, 이는 비교가 불가능하다는 것까진 알겠는데 왜 PartialEqOption<bool>이 아니라 bool을 리턴하고 PartialOrdOption<Ordering>을 리턴하는 걸까요?

생산성과의 타협

만약 PartialEqeqOption<bool>을 리턴한다면 어떤 일이 생길까요? 우리는 모든 동치관계에 대해 Option을 해소해야하며, 아마 코드는 매우 난잡해질 것입니다. 이는 언어의 실용적인 측면에서 좋지 않습니다. 모든 조건문마다 Option을 해소하는 코드를 추가해본다고 생각해보세요. 이는 callback-hell 과는 전혀 다른 차원의 난잡함이라고 볼 수 있습니다.

마찬가지로 lt, le, gt, ge 메서들 역시 Option<bool>을 리턴하게 한다면, 우린 모든 비교구문마다 Option을 해소하는 코드를 추가해야만 합니다. 이 역시 eqOption<bool>을 리턴하는 것과 마찬가지로 코드의 복잡함을 초래할 것입니다.

굳이 NaN으로 비교불가능하다는 것을 명확하게 표현하기보다는, 그저 IEEE 754 표준에 따라 NaN과의 비교는 항상 false라고 정의하는 편이 낫습니다. 즉, NaN == NaNfalse를 리턴하도록 하고, NaN < 10 등, 모든 NaN과의 비교는 항상 false를 리턴하도록 하는 것이 낫습니다. 실제로 수많은 언어들이 NaN을 모두 똑같이 처리하고 있습니다.

PartialOrd는 엄밀한 비교가 가능한 여지가 있지만 PartialEq는 여지가 없다

Rust는 partial_cmpOption<Ordering>을 리턴하도록 하여 우리에게 엄밀한 비교를 할 수 있도록 여지를 남겨두었습니다. 하지만 PartialEq는 그렇지 않습니다. 여기에는 타당한 이유가 있는데요. NaN == NaNfalse이면 NaN != NaNtrue이기 때문에, 동일한 값의 비교에 대해 ==!=는 항상 서로 대치되는 값이 나오게 됩니다. 하지만 순서비교는 이것이 불가능합니다.

NaN < NaNfalse라면 NaN >= NaNtrue가 되어야 하지만, 이는 말이 되지 않습니다. 따라서 eqOption<bool>을 리턴하지 않아도 ne와 항상 대칭되게 값이 나오지만, 순서비교는 그렇지 않습니다. 따라서 partial_cmpOption<Ordering>을 리턴합니다.

Reference

profile
undefined cat

0개의 댓글