(Common Lisp의 어두운 면) Equality

Posted on Nov 16, 2019

오늘은 커먼리습의 어두운 면을 이야기 해볼까. 보통 나는 리습 팬보이니까 리습에 대해 불리한 이야기는 잘 쓰지 않는거 같아서 한번 써보기로 생각했다. 그리고 놀랍게도 이 글의 끝에 가서는 다시 이런 리습의 결점을 리습의 위대함으로 승화시키는 단계까지 끌어가 보도록 하려고함.

뭐 커먼리습의 CLHS - HyperSpec을 읽다보면 비슷한데 아주 약간씩 미묘하게 달라서 지원하는 것들이 있다:

이렇게 나열해 놓으면 꽤 무서워 보이지만, 막상 차이를 이해하면 별로 복잡하지도 않아서 나중엔 더 편안해진다. (정말로)

이 글에서는 위에 나열한 것들의 차이를 정리하지 않겠다. 그냥 예시로만 보여봤다. 이번에는 값의 동치관계에 대해서만 집중하겠다.


1. 같은 값 비교 연산: 숫자, 심볼, 같은 객체를 참조하는지: EQ

오늘의 주제는 바로 ‘같은 값 비교 연산’이다. 혹은 동치관계연산자(同値關 係…, Equality operators).

가장 간단한 =, /= 부터 시작해볼까:

NUMBER 타입에 대해서만 한정지어 말했냐하면:

(= :foo :foo)
; Debugger entered on #<TYPE-ERROR expected-type: NUMBER datum: :FOO>

..처럼 Lisp에서 가장 Primitive한 값 타입의 하나인 심볼에 대해서도 완강히 지원하지 않으시기 때문이다.

오.. 마이 ㅂㅋㄱ… 그럴거 같애서 이제 조금 더 길이가 긴 연산자가 필요해진다.

  • Function EQ
    1. 숫자는 물론 심볼도 비교 할 수 있다.
    2. 이는 숫자, 심볼 비교에서 두 값이 완전히 같은 객체, 즉 참조가 같다는 원리를 이용한 것이다.

테스트해볼까:

(eq (+ 1 2) 3)
; => T

(eq :foo :FOO)
; => T

(eq 'bar 'BaR)
; => T

적당하다. 하지만:

(eq #\a #\a)
; => T

(eq #\a #\A)
; => NIL

(eq "foo" "foo")
; => NIL

(eq '(a b) '(a b))
; => NIL

그렇다. System Class CHARACTER#\a 도 대소문자 구분이 생기자마자 이 연산자로는 같지 않다.

문자의 대소문자 이야기는 잠깐 접어놓고, 나머지 문자열, 리스트에 대해서도 이런 결과를 얻은 이유를 설명하자면: EQ은 정의 그대로, “메모리 상에서 같은 값의 참조인지"만 판단하기 때문이다.

심볼키워드 심볼은 같은 값이 여러개 생기지 않고 항상 유일하게 하나만을 공유한다는걸 보장하니까 당연히 EQ으로 비교가 쉽다.

그리고 숫자의 경우에도 마찬가지일거 같다. 숫자의 경우 추측을 해보면, 이것도 심볼과 마찬가지로 메모리 상에서 레퍼런스에 담긴 값이 같은 값일거 같다. 왜냐하면, 숫자 값은 그 자체로 레퍼런스 값 그 자체로 저장되어 있을거라고 생각한다. (예: Tagged Pointer) 그리고 이런 tagged pointer의 아이디어가 처음 생긴 것도 리습이고, 그런 전통에 따라 현재는 분리되어 저장하더라도 EQ에서 그런 것처럼 동작하게 만들어 놓았으리라.

그래서 단순히 참조포인터에 저장하기 어려운 복합적인 구조를 갖는 복소수 값은 NUMBER타입임에도 EQ으로 같은지 알 수 없다.

(eq #c(1 2) #c(1 2))
; => NIL

다만, 문자열이나 리스트, cons의 경우에는 같은 값을 갖는 다른 메모리 위치이라면 EQ은 다른 값이라고 말한다.

(let ((a '(a b c))
      (b '(a b c)))
   (values :same-ref? (eq a a)
           :equal-val? (eq a b)))
; =>
;   :SAME-REF?
;     T
;   :EQUAL-VAL?
;     NIL

이런 값들에 대해서는 순서를 갖고 하나씩 어떻게 비교해야할지 이야기 해나가겠다.


2. “한 걸음 더”: EQL

EQ보다 한 글자 더 길어진 Function EQL.

  1. EQ의 모든 비교를 동일하게 수행. 1. 같은 참조인지를 체크: 심볼, 키워드, 모든 값에 대해서. 1. 숫자에 대해서도, 같은 타입, 같은 크기의 값인지를 비교.
  2. 거기에 더해서, CHARACTER도 비교를 할 수 있지만, 1. 대소문자를 구분한다.

확인해보자.

(eql #\a #\A)
; => NIL

(eql #\a #\a)
; => T

(eq #c(1 2) #c(1 2))
; => NIL

(eql #c(1 2) #c(1 2))
; => T

(eql #c(1 2.0) #c(1 2))
; => NIL

EQ 보다는 조금 더 비교가 가능함을 확인했다. 같은 크기를 갖는 #c(1 2)의 서로 다른 복소수 참조에 대해서 비교를 잘 해낸다.

그러나 여전히 cons, list, string등 조금이라도 복합적인 값은 비교를 하지 못한다.

(eql "foo" "foo")
; => NIL

(eql '(1 2 3) '(1 2 3))
; => NIL

(eql '(a . b) '(a . b))
; => NIL

3. 약간 더 구조가 있어도 지원: EQUAL

이제서야 스펠링을 그대로 타이핑하는 연산자다. 그리고 그만큼 = -> EQ -> EQL -> EQUAL 순서로 길어져서인지 꽤 원하는대로 비교를 해준다.

Function EQUAL

대부분 잘 비교해준다. 숫자, 심볼, 키워드, 문자열, 리스트, cons, pathname.

(equal "foo" "foo")
; => T

(equal "foo" "FOO")
; => NIL

(equal '(a b c) '(a b c))
; => T

(equal '(a . b) '(a . b))
; => T

…그런데 여전히 struct, CLOS object, array, hash table 값들은 비교를 못한다. 반대로 말해서 비교 가능한 값들은 cons을 기반으로 이루어졌거나 비교적 선형적인 문자열만이라고 볼 수 있다.

;;; Array
(equal #(1 2 3) #(1 2 3))
; => NIL

;;; Hash Table
(let ((a (alexandria:alist-hash-table '((apple . red) (yellow . banana))))
      (b (alexandria:alist-hash-table '((apple . red) (yellow . banana)))))
  (equal a b))
; => NIL


;;; Struct
(defstruct point-2d x y)
(let ((a (make-point-2d :x 1 :y 2))
      (b (make-point-2d :x 1 :y 2)))
   (equal a b))
; => NIL

;;; NOTE: CLOS Object은 어차피 Struct와 Slot을 처리하는 방식이 같을테니 생략.

(참고: 예제의 작성 편의를 위해 Alexandria 라이브러리를 사용해서 Alist을 hash table으로 바로 표현했다.)


4. 완전체?: EQUALP

그래서 Function EQUALP 까지 커먼리습에는 지원한다.1

(equalp #(1 2 3) #(1 2 3))
; => T

(let ((a (alexandria:alist-hash-table '((apple . red) (yellow . banana))))
      (b (alexandria:alist-hash-table '((apple . red) (yellow . banana)))))
  (equalp a b))
; => T

(let ((a (make-point-2d :x 1 :y 2))
      (b (make-point-2d :x 1 :y 2)))
  (equalp a b))
; => T

이제는 모두 다 평범한 기대대로 비교한다.


5. 문자, 문자열

설명하지 않겠다.

다만, -=-suffix인쪽은 (더 짧으니까) 대소문자 구분을 하고, 더 긴 이름이 대소문자를 무시하고 비교해준다.

(string= "abc" "ABC")
; => NIL

(string-equal "abc" "ABC")
; => T

6. 하지만 나는 더 특별하고 싶다: EQUALS

EQUALP면 이름도 길어질만큼 충분히 길어진거 같고, 왠만한 경우를 다 비교하는거 같은데 굳이 왜 EQUALS은 뭐고 이런게 필요한가?

하지만 이런게 필요할 때가 있긴 하다 –> Generic Equality and Comparison for Common Lisp

다른 프로그래밍 언어를 경험해봤다면 한번 정도는 봤었을 다음과 같은걸 하기 위해서:

  1. Python: __eq__
  2. Java: equals()

그렇다. 기본적으로 CLOS 객체를 만들거나, Struct을 정의해도 커먼리습은 모든 슬롯(slots)와 타입을 비교해서 같은지를 자동으로 비교해주는 EQUALP을 제공해서 보통은 파이썬이나 자바처럼 이런 메서드를 일일이 작성할 필요가 없다.

하지만 내 struct이나 CLOS객체에 특정 슬롯을 비교에서 제외하고 싶다면, 정말정말 유니크한 특별한 비교 연산을 지정해서 구현하고 싶다면 EQUALS을 쓴다.

EQUALS은 Quicklisp으로 간단히 불러올 수 있다. (ql:quickload :equals)

그리고 Inspector으로 #'EQUALS:EQUALS을 살펴보면 다음과 같이 설명한다:

  • Name: EQUALS:EQUALS
  • Arguments: (EQUALS::X EQUALS::Y &REST EQUALS::KEYS &KEY EQUALS::RECURSIVE &ALLOW-OTHER-KEYS)
  • Documentation:

The EQUALS generic functions defines methods to test for ’equality’ of two objects a and b. When two objects a and b are EQUALS under an appropriate and context-dependent notion of ’equality’, then the function returns T as result; otherwise EQUALS returns NIL as result.

If the argument recursive is T, then EQUALS may recurse down the ‘structure’ of a and b. The description of each known method contains the relevant information about its recursive dependent behavior.

특별한 파라미터 없이 그냥 2개의 X, Y을 받고, :RECURSIVE T 정도의 키 파라미터만 지정이 가능한 generic function이다.

generic function이기 때문에, 내가 원하는 클래스, struct등에 대해서 type specialize해서 구현하면 된다.

기본적으로 hash table, CLOS object, struct, string, array, character, cons, number에 대해서 모두 기본 구현한 메서드들을 제공한다.

하지만 hash table에 대해서는 어차피 specialization이 필요 없을테니 그냥 EQUALP을 쓸 것을 권한다.

CLOS object, struct에 대해서는 어차피 기본 구현 내용이 EQ을 이용한 완전히 같은 참조인지를 보는 것이기 때문에 EQUALP을 쓰길 권한다.

아니라면 다음과 같이 간단한 메서드 구현을 통해 해결한다:

(defmethod equals:equals ((x point-2d) (y point-2d) &rest args)
  (declare (ignore args))
  (equalp x y))

(let ((a (make-point-2d :x 1 :y 2))
      (b (make-point-2d :x 1 :y 2)))
  (equals:equals a b))
; => T

(let ((a (make-point-2d :x 1 :y 2))
      (b (make-point-2d :x 2 :y 2)))
  (equals:equals a b))
; => NIL

혹은 아예 Java, Python에서 이를 직접 구현하듯이 할수도 있다:

(defclass person ()
  ((name :initarg :name :accessor person-name)
   (age :initarg :age :accessor person-age)))

(setf jhyun (make-instance 'person :name "jhyun" :age 17)
      old-jhyun (make-instance 'person :name "jhyun" :age 1700)
      stranger (make-instance 'person :name "stranger" :age '???))

(defmethod equals:equals ((x person) (y person) &rest args)
  (declare (ignore args))
  (equalp (person-name x) (person-name y)))

위의 예제에서 name 슬롯만을 동치비교할 때 사용하도록 구현해봤다. 다른 언어들의 구현과 달리 x, y 끼리의 타입 비교, nullity check 같은건 어차피 defmethod의 type specifier에서 모두 걸러지니까 신경쓰지 않았다.

꽤 불만스럽게 지금까지 이야기를 끌고 왔는데, 이 방식이 장점도 많다는걸 생각해본다.

  1. 불필요하게 specialized equality checker을 매번 작성하지 않아도 대부분은 잘 동작한다.
    1. (Java에 Eclipse/IntelliJ 같은 IDE은 물론 Common-Lang, Guava은 물론 Java SE의 API에도 이걸 해결하기 위한 Helpers, Utils, Generators이 항상 넘치는거에 비하면 훨씬 좋은 상황이다.)
  2. 그리고 defmethod에서 이미 타입 매칭이나 그런걸 다 해결해놓았고, nil 같은 값도 타입이 있어서(NULL타입) 여기에 걸리니까 동치비교 메서드의 구현이 안전하고 간단하다.

커먼리습에서 이 주제에 대해서는 거의 모든 내용을 이 글에서 정리한거 같다.


7. 그냥 항상 EQUALS이나 EQUALP 쓰면 되는거 아닌가?

그럴지도 모르겠다. 하지만 그럴 필요가 없는 경우에 굳이 복잡하고 더 느린 연산을 적용할 필요가 없을 때도 많을거 같다. 그리고 그런 이유로 이렇게 세세하게 분류해서 커먼리습은 여러 레벨으로 등치비교를 제공한다.

내가 어떤 함수를 정의할 때 타입을 명확하게 지정했는지에 따라서 어떤 연산을 사용할지 명확하게 결정하기 쉽고 성능도 훨씬 차이가 날거라고 생각한다. 값의 타입을 실행시간에 알아내거나 타입에 따라서 generic function에서 매칭하는 method을 찾아서 dispatch하는건 아무래도 아주 약간은 더 느릴테니까.

커먼리습의 find 계열 함수와 같이 predicate을 인자로 받아서 시퀀스 등에 적용하는 경우도 그렇고, make-hash-table처럼 아예 자료구조를 만드는데도 각 요소를 어떻게 비교할지 predicate을 지정하기를 요구할 때가 많다.

그때 그때 어떤 값을 다루는지를 잘 결정해서 =에서부터 equals까지의 넓은 선택 범위 안에서 고르는게 유리하다고 생각한다. 그리고 이들이 잘 효율적으로 컴파일 시간에 문제를 미리 예측할 수 있도록 이 값들을 다루는 코드나 정의에서 type specifiers을 잘 지정해서 컴파일러가 체크하도록 유도를 잘하는 것도 커먼리습이 다이나믹 타입시스템임에도 컴파일러가 똑똑하게 코드를 타입에 따라 체크하고 최적화를 해줄 수 있으므로 중요하다.


맺음말

“the greatest single programming language ever designed”

“지금까지 설계된 단일 프로그래밍 언어 중 가장 위대한 언어”

– Alan Kay, on Lisp

Alan Kay께서는 Smalltalk을 통해 IDE, GUI, 마우스 등을 발명, 도입하셨습니다…

“Life is a tragedy when seen in close-up, but a comedy in long-shot”

“삶은 가까이 보면 비극이지만, 멀리서 보면 희극이다”

– Charlie Chaplin, 챨리 채플린

…그러게요.



  1. 이름의 끝에 -p-suffix은, predicate이라는걸 나타내는 컨벤션 전통. 요즘엔 -?을 붙이는 경우도 있는데, 커먼리습의 CLHS 시절에는 -p-suffix이 더 일반적인거 같다. (예: zerop, evenp, oddp; 반례: null), 참고: https://www.cliki.net/Naming+conventions ↩︎