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
, PartialOrd
와 Eq
, 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
만이 구현 대상이 되는 메서드입니다. ne
는 eq
를 부정한 결과를 리턴할 뿐입니다.
Rust에서 기본으로 제공하는 대부분의 타입들은 PartialEq
를 구현하고 있습니다. 숫자와 관련된 타입들 역시 모두 PartialEq
를 구현하고 있습니다. Eq
역시 마찬가지입니다. Rust에서 기본으로 제공하는 대부분의 타입들은 Eq
를 구현하고 있습니다. 단, 부동소수점에 해당하는 f32
와 f64
는 Eq
를 구현하고 있지 않습니다. 이는 위에서 언급했던대로, 부동소수점 세계에 존재하는 NaN
은 비교가 불가능한 값이기 때문입니다.
Rust에서 Eq
는 수학적으로 반사성(x == x)
, 대칭성(x == y -> y == x)
, 추이성(x == y && y == z -> x == z)
을 가지는 값들에 대해서만 정의하도록 하고 있습니다. 하지만 NaN
의 경우 이를 만족하지 않습니다. 그렇기에 NaN
을 포함하고 있는 f32
와 f64
는 Eq
를 구현하고 있지 않는 것입니다.
그렇다면 Eq
의 정의는 어떨까요? 사실 Eq
는 PartialEq
를 상속받는 trait입니다. 따로 추가적으로 구현해야 할 것은 없습니다. 즉, 일종의 marker trait입니다.
pub trait Eq: PartialEq<Self> {}
개발자는 위에서 언급한대로 반사성
, 대칭성
, 추이성
을 만족하는 PartialEq
구현체에 대해서만 Eq
를 구현해야만 합니다. 물론 Rust에서는 대부분의 경우 PartialEq
와 Eq
를 직접 구현할 일이 많지는 않습니다. 예를 들어 위에서 정의한 Point
타입은 다음과 같이 derive
attribute를 통해 컴파일러에게 구현을 위임할 수 있습니다.
#[derive(PartialEq, Eq)]
struct Point {
x: i32,
y: i32,
}
이는 Point
의 필드들이 모두 PartialEq
와 Eq
를 구현하고 있기 때문에 가능합니다. 만약 x
와 y
가 f32
라면 우린 Eq
를 구현할 수 없습니다.
// Trait `Eq` is not implemented for `f32` [E0277]
#[derive(PartialEq, Eq)]
struct Point {
x: f32,
y: f32,
}
또한 Eq
만을 구현할 수는 없습니다. Eq
는 PartialEq
를 super trait으로 갖기 때문에 PartialEq
를 먼저 구현해야만 합니다.
// Trait `PartialEq` is not implemented for `Point` [E0277]
#[derive(Eq)]
struct Point {
x: i32,
y: i32,
}
PartialOrd
, Ord
PartialOrd
는 PartialEq
와 마찬가지로 비교가 부분적으로는 가능하다는 의미를 담은 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))
}
}
PartialEq
의 eq
메서드는 반드시 구현해야만 하는 메서드인 것처럼, PartialOrd
의 partial_cmp
메서드도 반드시 구현해야만 하는 메서드입니다.
PartialEq
의 ne
가 eq
로부터 비롯된다면, lt
, le
, gt
, ge
메서드들은 모두 partial_cmp
로부터 비롯됩니다. 이들 메서드들이 각각 <
, <=
, >
, >=
연산자들로 오버로딩 되는 메서드들입니다.
그런데, PartialEq
의 eq
와 PartialOrd
의 partial_cmp
의 리턴값에는 차이가 있습니다. eq
의 경우 순수하게 bool
을 리턴하지만 partial_cmp
의 경우 Option<Ordering>
을 리턴합니다. 즉, 값이 존재하지 않는다는 의미인 None
을 리턴할 수도 있다는 뜻입니다.
Parital
이 NaN
으로부터 비롯됐고, 이는 비교가 불가능하다는 것까진 알겠는데 왜 PartialEq
는 Option<bool>
이 아니라 bool
을 리턴하고 PartialOrd
는 Option<Ordering>
을 리턴하는 걸까요?
만약 PartialEq
의 eq
가 Option<bool>
을 리턴한다면 어떤 일이 생길까요? 우리는 모든 동치관계에 대해 Option
을 해소해야하며, 아마 코드는 매우 난잡해질 것입니다. 이는 언어의 실용적인 측면에서 좋지 않습니다. 모든 조건문마다 Option
을 해소하는 코드를 추가해본다고 생각해보세요. 이는 callback-hell 과는 전혀 다른 차원의 난잡함이라고 볼 수 있습니다.
마찬가지로 lt
, le
, gt
, ge
메서들 역시 Option<bool>
을 리턴하게 한다면, 우린 모든 비교구문마다 Option
을 해소하는 코드를 추가해야만 합니다. 이 역시 eq
가 Option<bool>
을 리턴하는 것과 마찬가지로 코드의 복잡함을 초래할 것입니다.
굳이 NaN
으로 비교불가능하다는 것을 명확하게 표현하기보다는, 그저 IEEE 754 표준에 따라 NaN
과의 비교는 항상 false
라고 정의하는 편이 낫습니다. 즉, NaN == NaN
이 false
를 리턴하도록 하고, NaN < 10
등, 모든 NaN
과의 비교는 항상 false
를 리턴하도록 하는 것이 낫습니다. 실제로 수많은 언어들이 NaN
을 모두 똑같이 처리하고 있습니다.
PartialOrd
는 엄밀한 비교가 가능한 여지가 있지만 PartialEq
는 여지가 없다Rust는 partial_cmp
가 Option<Ordering>
을 리턴하도록 하여 우리에게 엄밀한 비교를 할 수 있도록 여지를 남겨두었습니다. 하지만 PartialEq
는 그렇지 않습니다. 여기에는 타당한 이유가 있는데요. NaN == NaN
이 false
이면 NaN != NaN
은 true
이기 때문에, 동일한 값의 비교에 대해 ==
와 !=
는 항상 서로 대치되는 값이 나오게 됩니다. 하지만 순서비교는 이것이 불가능합니다.
NaN < NaN
이 false
라면 NaN >= NaN
이 true
가 되어야 하지만, 이는 말이 되지 않습니다. 따라서 eq
는 Option<bool>
을 리턴하지 않아도 ne
와 항상 대칭되게 값이 나오지만, 순서비교는 그렇지 않습니다. 따라서 partial_cmp
는 Option<Ordering>
을 리턴합니다.