Clojure의 동등성(Equality)

Clojure의 동등성(Equality)

Clojure에서 값을 비교할 때 우리는 대부분 =를 써요. 그런데 이 =는 자바의 equals와 다른 점이 꽤 많아요. 이 문서는 Clojure의 동등성 개념을 다루는데요, =, ==, identical? 함수가 서로 어떻게 다르고, 자바의 equals 메서드와는 또 어떻게 다른지 살펴볼게요. hash 함수가 자바의 hashCode와 어떻게 다른지도 함께 설명할게요.

이 가이드의 앞부분은 가장 중요한 내용을 빠르게 참고할 수 있도록 요약해 두었고, 뒤로 갈수록 훨씬 자세한 내용을 다루게 돼요.

이 가이드의 내용은 특별히 언급하지 않는 한 Clojure 1.10.0의 동작을 설명해요.

출처: Clojure 공식문서

본문

요약

Clojure의 =같은 값을 나타내는 불변(immutable) 값끼리 비교할 때, 또는 같은 객체인 가변(mutable) 객체끼리 비교할 때 true가 돼요. 편의를 위해 자바 컬렉션끼리, 또는 자바 컬렉션과 Clojure의 불변 컬렉션끼리 내용이 같으면 =true를 반환해요. 다만 Clojure가 아닌 컬렉션을 쓸 때는 중요한 주의점이 몇 가지 있어요.

Clojure의 =는 두 개의 불변 스칼라 값에 대해 다음 경우 true예요.

  • 두 인자가 모두 nil, true, false이거나, 같은 문자(char), 같은 문자열(같은 문자 시퀀스)일 때
  • 두 인자가 모두 심볼이거나 모두 키워드인데, 네임스페이스와 이름이 같을 때
  • 두 인자가 같은 '범주(category)'에 속하는 숫자이고 숫자로서도 같을 때. 여기서 범주는 다음 중 하나예요.
    • 정수 또는 비율(ratio)
    • 부동소수점(float 또는 double)
    • BigDecimal

Clojure의 =는 두 컬렉션에 대해 다음 경우 true예요.

  • 두 인자가 모두 순차적(sequential) 컬렉션(시퀀스, 리스트, 벡터, 큐, 또는 java.util.List를 구현한 자바 컬렉션)이고 =한 요소들이 같은 순서로 있을 때
  • 두 인자가 모두 집합(java.util.Set을 구현한 자바 집합 포함)이고 =한 요소들을 가지되 순서는 무시할 때
  • 두 인자가 모두 맵(java.util.Map을 구현한 자바 맵 포함)이고 =한 키 그리고 값들을 가지되 항목 순서는 무시할 때
  • 두 인자가 모두 defrecord로 만든 레코드이고 =한 키 그리고 값들을 가지되 순서는 무시하고 또한 같은 타입일 때. 레코드와 맵을 비교하면 키와 값에 관계없이 =false를 반환해요. 타입이 다르기 때문이에요.

Clojure의 =는 두 개의 가변 Clojure 객체(즉 var, ref, atom, agent) 또는 두 개의 "대기 중(pending)"인 Clojure 객체(즉 future, promise, delay)에 대해 다음 경우 true예요.

  • 두 인자가 같은 객체, 즉 (identical? x y)true일 때

다른 모든 타입에 대해서는:

  • 두 인자가 deftype로 정의한 같은 타입이면, 그 타입의 equiv 메서드를 호출하고 그 반환값이 (= x y)의 값이 돼요.
  • 다른 타입이면 자바의 x.equals(y)true일 때예요.

Clojure의 ==숫자 값만을 위해 만들어진 함수예요.

  • ==는 서로 다른 숫자 범주에 속하는 숫자(예: 정수 0과 부동소수점 0.0) 사이에서도 쓸 수 있어요.
  • 비교 대상 중 하나라도 숫자가 아니면 예외가 던져져요.

===를 인자 3개 이상으로 호출하면, 모든 인접한 쌍이 각각 ===일 때 결과가 true가 돼요. hash는 아래에 나오는 예외를 제외하면 =와 일관적이에요.

주의할 점, 또는 예상 밖의 결과들이 있어요.

  • Clojure가 아닌 컬렉션을 Clojure의 해시 기반 컬렉션에 넣으면(맵의 키나 집합의 요소로) 해시 동작의 차이 때문에, Clojure 대응 컬렉션과도 같게 보이지 않아요. (아래 동등성과 해시CLJ-1372 참고)
  • 컬렉션을 =로 비교하면 컬렉션 안의 숫자들도 =로 비교되기 때문에, 위의 세 숫자 범주가 여기서도 중요해져요.
  • '숫자가 아님(Not a Number)' 값들, 즉 ##NaN, Float/NaN, Double/NaN그 어떤 것과도 ===가 되지 않아요. 자기 자신과도 말이죠. 권장사항: =로 서로 비교하고 싶은 Clojure 데이터 구조 안에는 ##NaN을 넣지 않는 게 좋아요.
  • 0.0-0.0=해요.
  • Clojure 정규식(예: #"a.*bc")은 자바의 java.util.regex.Pattern 객체로 구현돼요. 자바의 equals는 두 Pattern 객체에 대해 (identical? re1 re2)를 반환하는데, 문서상으로는 이 객체들이 불변이라고 되어 있어도 그래요. 그래서 (= #"abc" #"abc")false가 되고, =는 두 정규식이 우연히 메모리에서 같은 객체일 때만 true가 돼요. 권장사항: =로 비교하고 싶은 데이터 구조 안에는 정규식 인스턴스를 넣지 않는 게 좋아요. 필요하다면 문자열로 바꿔서 쓸 수 있어요. 예: (str #"abc")"abc". (CLJ-1182 참고)
  • Clojure 영속 큐는 java.util.List를 구현한 자바 컬렉션과는 절대 =가 되지 않아요. 같은 순서로 =한 요소들을 가지고 있어도요. (CLJ-1059 참고)
  • 정렬 맵(sorted map)과 다른 맵을 =로 비교할 때, 키 타입이 달라서(예: 키워드 vs 숫자) 키끼리 compare가 예외를 던지면, 상황에 따라 예외가 발생할 수 있어요. (CLJ-2325 참고)

대부분의 경우 hash=와 일관적이에요. 즉 (= x y)라면 (= (hash x) (hash y))라는 거죠. 이 성질이 성립하지 않는 값이나 객체가 있으면, Clojure의 해시 기반 컬렉션은 그 항목들을 제대로 찾거나 제거하지 못해요. 해시 기반 집합에서 그 항목들이 요소로 있거나, 해시 기반 맵에서 그 항목들이 키로 있을 때 말이에요.

  • 숫자에 대해서는 특별한 float·double 값을 제외하면 hash=와 일관적이에요. 권장사항: (double x)로 float를 double로 바꾸면 이 문제를 피할 수 있어요.
  • Clojure의 불변 컬렉션과 그것의 Clojure가 아닌 대응 컬렉션 사이에서는 hash=와 일관적이지 않아요. 자세한 내용은 동등성과 해시 섹션을 보세요. 권장사항: Clojure가 아닌 컬렉션을 다른 Clojure 데이터 구조에 넣기 전에 Clojure의 불변 대응 컬렉션으로 바꿔 주는 게 좋아요.
  • 역사적 참고: (seq (vector-of :int 0 1 2)) 같은 호출에서 반환되는 VecSeq 클래스 객체에 대해서는 hash=와 일관적이지 않았는데, Clojure 1.10.2에서 고쳐졌어요. (CLJ-1364 참고)

소개

Clojure에서 동등성은 대부분 =로 검사해요.

user> (= 2 (+ 1 1))
true
user> (= (str "fo" "od") "food")
true

자바의 equals 메서드와 달리, Clojure의 =는 서로 다른 타입이어도 많은 값을 true로 반환해요.

user> (= (float 314.0) (double 314.0))
true
user> (= 3 3N)
true

=는 두 숫자가 같은 숫자 값을 가질 때 항상 true를 반환하지는 않아요.

user> (= 2 2.0)
false

서로 다른 숫자 범주끼리 숫자 동등성을 검사하고 싶다면 ==를 쓰세요. 자세한 내용은 아래 숫자 섹션을 참고해요.

같은 요소를 같은 순서로 가진 순차적 컬렉션(시퀀스, 벡터, 리스트, 큐)은 서로 같아요.

user> (range 3)
(0 1 2)
user> (= [0 1 2] (range 3))
true
user> (= [0 1 2] '(0 1 2))
true
;; 순서가 다르면 =가 아님
user> (= [0 1 2] [0 2 1])
false
;; 요소 개수가 다르면 =가 아님
user> (= [0 1] [0 1 2])
false
;; 2와 2.0이 =가 아니므로 =가 아님
user> (= '(0 1 2) '(0 1 2.0))
false

두 집합은 같은 요소들을 가지면 같아요. 집합은 원래 순서가 없는데, 정렬 집합이라도 동등성 비교에서는 정렬 순서를 고려하지 않아요.

user> (def s1 #{1999 2001 3001})
#'user/s1
user> s1
#{2001 1999 3001}
user> (def s2 (sorted-set 1999 2001 3001))
#'user/s2
user> s2
#{1999 2001 3001}
user> (= s1 s2)
true

두 맵은 같은 키 집합을 가지고, 각 키가 양쪽 맵에서 같은 값으로 매핑될 때 같아요. 집합과 마찬가지로 맵도 순서가 없고, 정렬 맵에서도 정렬 순서는 고려하지 않아요.

user> (def m1 (sorted-map-by > 3 -7 5 10 15 20))
#'user/m1
user> (def m2 {3 -7, 5 10, 15 20})
#'user/m2
user> m1
{15 20, 5 10, 3 -7}
user> m2
{3 -7, 5 10, 15 20}
user> (= m1 m2)
true

벡터가 인덱스 기반이고 맵과 비슷한 성질을 일부 가지긴 하지만, Clojure에서 맵과 벡터는 절대 =로 비교되지 않아요.

user> (def v1 ["a" "b" "c"])
#'user/v1
user> (def m1 {0 "a" 1 "b" 2 "c"})
#'user/m1
user> (v1 0)
"a"
user> (m1 0)
"a"
user> (= v1 m1)
false

Clojure 컬렉션에 붙은 메타데이터는 비교할 때 무시돼요.

user> (def s1 (with-meta #{1 2 3} {:key1 "set 1"}))
#'user/s1
user> (def s2 (with-meta #{1 2 3} {:key1 "set 2 here"}))
#'user/s2
user> (binding [*print-meta* true] (pr-str s1))
"^{:key1 \"set 1\"} #{1 2 3}"
user> (binding [*print-meta* true] (pr-str s2))
"^{:key1 \"set 2 here\"} #{1 2 3}"
user> (= s1 s2)
true
user> (= (meta s1) (meta s2))
false

defrecord로 만든 레코드는 여러 면에서 Clojure 맵처럼 동작해요. 하지만 레코드는 같은 타입의 레코드끼리만, 그것도 같은 키와 같은 값을 가질 때만 =가 돼요. 맵과는 같은 키와 값을 가져도 절대 같지 않아요.

레코드를 정의하는 이유는 다른 타입과 구분되는 고유한 타입을 만들기 위해서예요. Clojure 프로토콜과 멀티메서드에서 각 타입이 자기만의 동작을 가지게 하고 싶은 거죠.

user=> (defrecord MyRec1 [a b])
user.MyRec1
user=> (def r1 (->MyRec1 1 2))
#'user/r1
user=> r1
#user.MyRec1{:a 1, :b 2}

user=> (defrecord MyRec2 [a b])
user.MyRec2
user=> (def r2 (->MyRec2 1 2))
#'user/r2
user=> r2
#user.MyRec2{:a 1, :b 2}

user=> (def m1 {:a 1 :b 2})
#'user/m1

user=> (= r1 r2)
false             ; r1과 r2는 타입이 다르다
user=> (= r1 m1)
false             ; r1과 m1은 타입이 다르다
user=> (into {} r1)
{:a 1, :b 2}      ; 레코드를 맵으로 "변환"하는 한 가지 방법
user=> (= (into {} r1) m1)
true              ; 변환된 맵은 m1과 =이다

Clojure의 =는 숫자와 Clojure 컬렉션을 제외한 모든 타입에서 자바의 equals와 똑같이 동작해요.

불리언과 문자는 동등성이 단순해요.

문자열도 대부분 단순한데, 유니코드의 일부 경우만 예외예요. 서로 다른 유니코드 문자 시퀀스로 이루어진 문자열이 화면에서는 같아 보일 수 있고, 일부 애플리케이션에서는 =false를 반환해도 같게 취급해야 하는 경우가 있어요. 관심이 있다면 유니코드 등가성(Unicode equivalence)에 관한 Wikipedia 페이지의 "Normalization" 항목을 보세요. 이런 처리가 필요하면 ICU(International Components for Unicode for Java) 같은 라이브러리가 도움을 줄 수 있어요.

두 심볼은 네임스페이스와 심볼 이름이 같으면 같아요. 두 키워드도 같은 조건에서 같고요. Clojure는 키워드 동등성 검사를 특히 빠르게 하는데(단순한 포인터 비교), Keyword 클래스의 intern 메서드가 같은 네임스페이스·이름을 가진 모든 키워드가 같은 키워드 객체를 반환하도록 보장하기 때문이에요.

숫자

자바의 equals는 두 숫자에 대해 타입과 숫자 값이 모두 같아야만 true예요. 그래서 Integer 1과 Long 1도 타입이 다르므로 equalsfalse예요. 예외: 자바 equals는 수치적으로 같아도 scale이 다른 두 BigDecimal 값(예: 1.50M과 1.500M)에 대해서도 false예요. 이 동작은 BigDecimal 메서드 equals에 문서화되어 있어요.

Clojure의 =는 '범주'와 숫자 값이 같으면 true예요. 범주는 다음 중 하나예요.

  • 정수 또는 비율(ratio). 정수에는 Byte, Short, Integer, Long, BigInteger, clojure.lang.BigInt 같은 모든 자바 정수 타입이 포함되고, 비율은 clojure.lang.Ratio라는 자바 타입으로 표현돼요.
  • 부동소수점: FloatDouble
  • 10진수: BigDecimal

그래서 (= (int 1) (long 1))은 같은 정수 범주에 속하므로 true지만, (= 1 1.0)은 범주가 다르므로(정수 vs 부동소수점) false예요. 정수와 비율은 Clojure 구현에서 서로 별개의 타입이지만, =를 위해서는 실질적으로 같은 범주로 취급돼요. 비율에 대한 산술 연산 결과는 정수라면 자동으로 정수로 변환돼요. 따라서 Ratio 타입인 Clojure 숫자는 어떤 정수와도 같을 수 없고, =는 비율과 정수를 비교할 때 항상 올바른 숫자 답(false)을 줘요.

Clojure에는 숫자 비교에만 쓰는 ==도 있어요. 이것은 =true일 때 항상 true를 반환하고, 범주가 달라도 수치적으로 같으면 true를 반환해요. 그래서 (= 1 1.0)false지만 (== 1 1.0)true예요.

=가 숫자에 서로 다른 범주를 쓰는지 궁금할 수 있어요. 만약 ===처럼 동작한다면 hash=와 일관적으로 만드는 게 어려울(가능하다 해도) 테니까요(동등성과 해시 섹션 참고). (float 1.5), (double 1.5), BigDecimal 값 1.50M, 1.500M, 그리고 비율 (/ 3 2) 모두에 같은 해시 값을 반환하도록 보장된 hash를 써 보려고 상상해 보세요.

Clojure는 값이 집합의 요소나 맵의 키로 쓰일 때 그 동등성 비교에 =를 사용해요. 그래서 숫자 요소를 가진 집합이나 숫자 키를 가진 맵을 쓸 때는 Clojure의 숫자 범주가 작용하게 돼요.

부동소수점은 대개 근삿값

부동소수점 값은 그 근사적인 성질을 아직 배우지 못했다면 놀랄 만한 방식으로 동작할 수 있어요. 고정된 비트 수로 표현되기 때문에 많은 값을 정확히 표현하지 못하고 근사(또는 범위 밖)해야 하는 경우가 많아요. 이것은 어떤 프로그래밍 언어의 부동소수점에서도 마찬가지예요.

user> (def d1 (apply + (repeat 100 0.1)))
#'user/d1
user> d1
9.99999999999998
user> (== d1 10.0)
false

수치 해석(Numerical Analysis)이라는 학문 분야 전체가 수치 근사 알고리즘을 연구하는 데 쓰여요. 부동소수점 연산의 순서를 신중하게 설계해 근사 답과 정확한 답 사이의 차이에 대한 보증을 주는 Fortran 코드 라이브러리들이 있어요. "What Every Computer Scientist Should Know About Floating-Point Arithmetic"는 더 자세한 내용을 원한다면 읽을 만한 글이에요.

적어도 어떤 종류의 문제에서는 정확한 답을 원한다면 비율이나 BigDecimal이 맞을 수 있어요. 다만 이것들은 필요한 자릿수가 늘어나면(예: 산술 연산을 많이 거친 뒤) 가변적인 메모리 양을 필요로 하고, 계산 시간도 상당히 늘어나요. 파이나 √2 같은 값을 정확히 구하는 데는 도움이 되지 않기도 해요.

부동소수점 "숫자가 아님(NaN)"

Clojure는 표준 IEEE 754로 정의된 자바의 double 크기 부동소수점(64비트)을 사용해요. 자기 자신과도 같지 않은 특별한 값 NaN("Not A Number")이 있는데, Clojure는 이 값을 상징적인 값 ##NaN으로 표현해요.

user> (Math/sqrt -1)
##NaN
user> (= ##NaN ##NaN)
false
user> (== ##NaN ##NaN)
false

이 "값"이 데이터에 나타나면 꽤 이상한 동작이 생겨요. ##NaN을 집합 요소나 맵 키로 추가할 때는 오류가 발생하지 않지만, 그다음에 그걸 찾을 수도 없어요. disjdissoc 같은 함수로 제거할 수도 없고요. 그걸 포함한 컬렉션에서 만든 시퀀스에는 정상적으로 나타나요.

user> (def s1 #{1.0 2.0 ##NaN})
#'user/s1
user> s1
#{2.0 1.0 ##NaN}
user> (s1 1.0)
1.0
user> (s1 1.5)
nil
user> (s1 ##NaN)
nil             ; ##NaN은 자기 자신과 =가 아니므로 집합에서 찾을 수 없다

user> (disj s1 2.0)
#{1.0 ##NaN}
user> (disj s1 ##NaN)
#{2.0 1.0 ##NaN}    ; 결과에 ##NaN이 여전히 남아 있다!

많은 경우 ##NaN을 포함한 컬렉션은, 같아 보여도 다른 컬렉션과 =가 되지 않아요. (= ##NaN ##NaN)false이기 때문이에요.

user> (= [1 ##NaN] [1 ##NaN])
false

이상하게도 ##NaN을 포함한 컬렉션이 =가 돼야 할 것처럼 보이는데 실제로 =가 되는 예외적인 경우도 있어요. (identical? ##NaN ##NaN)true이기 때문이에요.

user> (def s2 #{##NaN 2.0 1.0})
#'user/s2
user> s2
#{2.0 1.0 ##NaN}
user> (= s1 s2)
true

자바는 부동소수점 값에 대한 equals 메서드에 특별한 경우를 두어 ##NaN이 자기 자신과 같게 만들어요. Clojure의 ===는 그렇게 하지 않아요.

user> (.equals ##NaN ##NaN)
true

동등성과 해시

자바는 객체 쌍의 동등성을 비교하기 위한 equals가 있어요.

자바에는 이 동등성 개념과 일관적인(또는 적어도 그래야 한다고 문서화된) hashCode 메서드가 있어요. 즉 equalstrue인 임의의 두 객체 xy에 대해 x.hashCode()y.hashCode()도 같다는 뜻이에요.

이 해시 일관성 덕분에 hashCode로 내부적으로 해시 기법을 쓰는 맵·집합 같은 해시 기반 데이터 구조를 구현할 수 있어요. 예를 들어 해시 테이블로 집합을 구현할 수 있고, 서로 다른 hashCode 값을 가진 객체는 서로 다른 해시 버킷에 들어갈 수 있으며, 서로 다른 해시 버킷에 있는 객체는 절대 서로 같지 않다는 게 보장돼요.

Clojure도 비슷한 이유로 =hash를 가져요. Clojure의 =가 자바 equals보다 더 많은 객체 쌍을 서로 같다고 보기 때문에, Clojure의 hash는 더 많은 객체 쌍에 대해 같은 해시 값을 반환해야 해요. 예를 들어 =한 요소들의 시퀀스가 시퀀스에 있든 벡터에 있든 리스트에 있든 큐에 있든 hash는 항상 같은 값을 반환해요.

user> (hash ["a" 5 :c])
1698166287
user> (hash (seq ["a" 5 :c]))
1698166287
user> (hash '("a" 5 :c))
1698166287
user> (hash (conj clojure.lang.PersistentQueue/EMPTY "a" 5 :c))
1698166287

그런데 Clojure의 불변 컬렉션과 그 Clojure가 아닌 대응 컬렉션을 비교할 때 hash=와 일관적이지 않기 때문에, 둘을 섞어 쓰면 아래 예시처럼 바람직하지 않은 동작이 생겨요.

user=> (def java-list (java.util.ArrayList. [1 2 3]))
#'user/java-list
user=> (def clj-vec [1 2 3])
#'user/clj-vec

;; 클래스는 달라도 =다
user=> (= java-list clj-vec)
true
user=> (class java-list)
java.util.ArrayList
user=> (class clj-vec)
clojure.lang.PersistentVector

;; 그런데 해시 값은 서로 다르다

user=> (hash java-list)
30817
user=> (hash clj-vec)
736442005

;; java-list와 clj-vec이 해시 값을 쓰지 않는 컬렉션
;; (벡터나 array-map 같은)에 들어가면 그 컬렉션들도 같아진다.

user=> (= [java-list] [clj-vec])
true
user=> (class {java-list 5})
clojure.lang.PersistentArrayMap
user=> (= {java-list 5} {clj-vec 5})
true
user=> (assoc {} java-list 5 clj-vec 3)
{[1 2 3] 3}

;; 그러나 java-list와 clj-vec이 해시 값을 쓰는 컬렉션
;; (hash-set이나 hash-map의 키 같은)에 들어가면,
;; 해시 값이 다르기 때문에 그 컬렉션들은 같지 않다.

user=> (class (hash-map java-list 5))
clojure.lang.PersistentHashMap
user=> (= (hash-map java-list 5) (hash-map clj-vec 5))
false               ; 아쉽지만 true가 아니다
user=> (= (hash-set java-list) (hash-set clj-vec))
false               ; 이것도 true가 아니다

user=> (get (hash-map java-list 5) java-list)
5
user=> (get (hash-map java-list 5) clj-vec)
nil                 ; 아마도 5를 기대했을 것이다

user=> (conj #{} java-list clj-vec)
#{[1 2 3] [1 2 3]}          ; 아마도 #{[1 2 3]}을 기대했을 것이다
user=> (hash-map java-list 5 clj-vec 3)
{[1 2 3] 5, [1 2 3] 3}      ; 아마도 {[1 2 3] 3}을 원했을 것이다

Clojure에서 맵을 쓸 때 대부분 array map을 쓸지 hash map을 쓸지 지정하지 않아요. 기본적으로 키가 8개 이하이면 array map, 8개를 넘으면 hash map이 사용돼요. 맵에 연산을 할 때 Clojure 함수가 구현을 알아서 선택해 주죠. 그래서 array map을 일관되게 쓰려고 해도, 맵이 커지면서 hash map을 자주 받게 돼요.

우리는 Clojure에서 해시 기반 집합·맵을 피하라고 권하지 않아요. 이들은 해시를 이용해 연산에서 높은 성능을 내거든요. 대신 Clojure 컬렉션 안의 부분으로 Clojure가 아닌 컬렉션을 쓰지 말라고 권해요. 주로 그런 Clojure가 아닌 컬렉션 대부분이 가변적이고, 가변성은 종종 미묘한 버그로 이어지기 때문이에요. 또 다른 이유는 hash=와 일관적이지 않다는 점이에요.

java.util.List, java.util.Set, java.util.Map을 구현한 자바 컬렉션과, Clojure의 hash=와 일관적이지 않은 몇 종류의 값들에서도 비슷한 동작이 일어나요.

해시 비일관 값(리스트나 벡터 같은 순차적 컬렉션의 요소로조차도)을 어떤 Clojure 컬렉션 안의 부분으로 쓰면, 그 컬렉션들도 서로 해시 비일관이 돼요. 컬렉션의 해시 값이 부분들의 해시 값을 결합해 계산되기 때문이에요.

Clojure가 아닌 컬렉션의 해시 비일관에 대한 역사적 배경

Clojure가 아닌 컬렉션에서 hash가 왜 =와 일관적이지 않은지 궁금할 거예요. Clojure가 아닌 컬렉션은 Clojure가 생기기 훨씬 전부터 자바의 hashCode 메서드를 써 왔어요. Clojure가 처음 개발될 때도 컬렉션 요소에서 해시 함수를 계산하는 공식으로 hashCode가 쓰던 것과 같은 공식을 사용했어요.

Clojure 1.6.0이 배포되기 전에, hash 함수에 hashCode를 쓰면 작은 컬렉션을 집합 요소나 맵 키로 사용할 때 해시 충돌이 많이 생길 수 있다는 게 발견됐어요.

예를 들어, 100행 100열의 2차원 격자의 내용을 키가 [0, 99] 범위의 숫자 두 개로 된 벡터인 맵으로 나타내는 Clojure 프로그램을 상상해 보세요. 이 격자에는 이런 점이 10,000개 있으므로 맵에 키가 10,000개이지만, hashCode는 3,169개의 서로 다른 결과만 내요.

user=> (def grid-keys (for [x (range 100), y (range 100)]
                        [x y]))
#'user/grid-keys
user=> (count grid-keys)
10000
user=> (take 5 grid-keys)
([0 0] [0 1] [0 2] [0 3] [0 4])
user=> (take-last 5 grid-keys)
([99 95] [99 96] [99 97] [99 98] [99 99])
user=> (count (group-by #(.hashCode %) grid-keys))
3169

따라서 맵이 기본 Clojure hash-map 구현을 쓴다면 해시 버킷당 평균 10,000 / 3,169 = 3.16번의 충돌이 생겨요.

Clojure 개발자들은 여러 대안 해시 함수를 분석했고, Clojure 1.6.0부터 사용된 Murmur3 해시 함수 기반의 하나를 선택했어요. 이것은 컬렉션의 여러 요소의 해시를 결합하는 방식도 자바의 hashCode가 쓰는 방식과 달라요.

그 당시 Clojure는 Clojure가 아닌 컬렉션에도 새 기법을 쓰도록 hash를 바꿀 수 있었지만, 그러면 hash를 구현하는 데 쓰는 hasheq라는 자바 메서드가 크게 느려질 것이라고 판단했어요. 지금까지 고려된 접근 방식은 CLJ-1372를 보세요. 다만 아직 이 글을 쓰는 시점까지 경쟁력 있게 빠른 방법을 찾은 사람은 없어요.

hash=와 일관적이지 않은 다른 경우들

서로 =한 일부 Float·Double 값들은 hash 값이 일관적이지 않아요.

user> (= (float 1.0e9) (double 1.0e9))
true
user> (map hash [(float 1.0e9) (double 1.0e9)])
(1315859240 1104006501)
user> (hash-map (float 1.0e9) :float-one (double 1.0e9) :oops)
{1.0E9 :oops, 1.0E9 :float-one}

부동소수점 코드에서 한 타입만 일관되게 쓰면 FloatDouble 사이의 해시 비일관을 피할 수 있어요. Clojure는 부동소수점 값에 기본적으로 double을 쓰므로, double을 고르는 게 가장 편할 수 있어요.

Rich Hickey는 FloatDouble 타입에 대한 이 해시 비일관을 고치는 것이 Clojure의 범위 밖이라고 결정했어요(CLJ-1036의 코멘트에서 언급됨). float와 double을 비교할 때 =가 항상 false를 반환하도록 바꾸는 제안(CLJ-1649)이 등록되어 있는데, 이렇게 하면 hash에 대한 제약을 없애 hash=와 일관적으로 만들 수 있지만, 아직 결정은 내려지지 않았어요.

자신의 타입에 동등성 정의하기

이걸 어떻게 하는지, 그리고 훨씬 더 많은 것을 보려면 아래 프로젝트들의 코드를 보세요. 특히 표준 자바 객체의 equalshashCode 메서드, 그리고 Clojure의 자바 메서드인 equivhasheq=hash가 동작하는 방식에 가장 관련이 깊어요.

참고 자료와 배경

Henry Baker의 "Equal Rights for Functional Objects, or, the More Things Change, The More They Are the Same" 논문에는 Clojure의 =에 영감을 준 Common Lisp 함수 EGAL의 코드가 들어 있어요. 불변 값에는 "깊은 동등성(deep equality)"이 말이 되지만, 가변 객체에는(가변 객체가 메모리에서 같은 객체가 아니라면) 그다지 말이 되지 않는다는 생각은 프로그래밍 언어와 무관해요.

EGAL과 Clojure =의 몇 가지 차이점은 아래에 설명해요. 이것들은 EGAL 동작에 대한 꽤 난해한 세부사항들이라, Clojure의 =를 이해하는 데 반드시 알 필요는 없어요.

가변 컬렉션과 다른 것 비교하기

EGAL은 가변 객체를 다른 어떤 것과 비교할 때, 그 상대가 메모리에서 같은 가변 객체가 아니라면 false로 정의돼요.

편의상 Clojure의 =는 Clojure의 불변 컬렉션과 Clojure가 아닌 컬렉션을 비교할 때 일부 경우 true를 반환하도록 설계됐어요.

임의의 컬렉션이 가변인지 불변인지 판단할 자바 메서드가 없으므로, Clojure에서 EGAL의 의도된 동작을 구현하는 것은 불가능해요. 다만 인자 중 하나가 Clojure가 아닌 컬렉션이면 항상 false를 반환한다면 =EGAL에 더 "가까워진다"고 볼 수는 있어요.

지연 값과 대기 중인 값

Baker는 EGAL이 값을 비교할 때 지연 값들을 강제로 평가하기를 권해요("Equal Rights for Functional Objects" 논문의 Section 3. J. "Lazy Values" 참고). 지연 시퀀스를 다른 순차적인 것과 비교할 때 Clojure의 =는 실제로 지연 시퀀스의 평가를 강제하는데, =가 아닌 시퀀스 요소에 도달하면 멈춰요. range가 만드는 것 같은 청크(chunked) 시퀀스는 그 지점보다 조금 더 평가가 진행될 수 있는데, 이는 Clojure에서 지연 시퀀스의 일부를 평가하게 만드는 어떤 사건에서든 마찬가지예요.

Clojure의 =는 delay, promise, future 객체를 비교할 때 deref를 하지 않아요. 대신 identical?로 비교해서, deref를 호출하면 =한 값이 나올 경우에도 메모리에서 같은 객체일 때만 true를 반환해요.

클로저(Closures)

Baker는 EGAL클로저를 서로 비교할 때 일부 경우 true를 반환할 수 있는 방법을 자세히 설명해요("Equal Rights for Functional Objects" 논문의 Section 3. D. "Equality of Functions and Function-Closures" 참고).

Clojure의 =는 함수나 클로저를 인자로 받으면 둘이 서로 identical?일 때만 true를 반환해요.

Baker가 EGAL을 이렇게 정의하도록 동기부여한 이유는, 일부 Lisp 계열 언어에서 객체를 나타내는 데 클로저를 흔히 쓰기 때문인 것 같아요. 그런 객체는 가변 상태나 불변 값을 담을 수 있죠(아래 예시 참고). Clojure에는 불변 값과 가변 객체를 만드는 여러 다른 방법(레코드, reify, proxy, deftype 등)이 있으므로, 그런 목적으로 클로저를 쓰는 건 흔하지 않아요.

(defn make-point [init-x init-y]
  (let [x init-x
        y init-y]
    (fn [msg]
      (cond (= msg :get-x) x
            (= msg :get-y) y
            (= msg :get-both) [x y]
            :else nil))))

user=> (def p1 (make-point 5 7))
#'user/p1
user=> (def p2 (make-point -3 4))
#'user/p2
user=> (p1 :get-x)
5
user=> (p2 :get-both)
[-3 4]
user=> (= p1 p2)
false             ; p1과 p2는 x, y 값이 다르므로 이게 false인 게 맞다
user=> (def p3 (make-point 5 7))
#'user/p3
user=> (= p1 p3)
false             ; Baker의 EGAL은 여기서 true를 반환한다. Clojure의
                  ; =는 p1과 p3가 identical?가 아니므로 false를 반환한다

더 알아보기