관리 메뉴

HAMA 블로그

스칼라 강좌 (12) - 객체의 동일성 본문

Scala

스칼라 강좌 (12) - 객체의 동일성

[하마] 이승현 (wowlsh93@gmail.com) 2016. 7. 8. 21:57

객체의 동일성

 
두개의 객체가 동일한지 아닌지 구분하는 작업은 별거 아닌거 같지만 , 
꽤나 복잡하고 미묘한 일들이 도사리고 있습니다. 방심하다 망하죠. ;;
게다가 언어 마다 다릅니다. 테스트만이 살길~
 
* 2007년 상당량의 자바코드를 연구한 논문에서는 거의 대부분의 equals 메소드에 오류가 있다는
 결론을 내릴 정도로 상상 이상으로 실수가 많
답니다. OTL 
 
이 글 에서는 중요 포인트만 딱딱 집어서 설명 해 보겠습니다. ( 모든 걸 설명하지 않습니다. ) 
혹시 더 자세하게 파헤치고 싶은 분이라면  아래 서적을 참고 하시구요. (꼭 읽어보길 당부..)
 
자바 : Effective Java 2판 - 항목 8,9 
스칼라 :  Programming in Scala 2판 - 30장
 
먼저 익숙한 자바로 먼저 살펴보고, 그 후에 스칼라로 알아보겠습니다.  
객체의 동일성을 체크하는데 두 언어는 다른 생각을 가지고 있다는 점   체크하세요~
 
C++
* C++ 의 경우는 객체 비교시 포인터의 주소로 하거나 ,  == 연산자 오버로딩을 통해서 처리 하죠. equals 같은건 자체 제공하지 않습니다. string 클래스 경우 compare 함수와 == 연산자 오버로딩을 제공합니다.
 
Python 
* Python 의 경우  디폴트는 객체의 id 를 비교하며  문자열은 ==  연산자로 객체의 내용을 비교하여 True, False를 판정하며,   __eq__ , __ne__ , __hash__ 메소드를 오버라이딩 하여 사용자 정의 해줍니다.  특이한건 저 메소드를 정의하면 == , != 의 정의가 자동으로 바뀌어 집니다. (자바는 안그러죠? 스칼라는 파이썬과 비슷합니다) 동일한 참조를 비교할때  is  를 사용합니다.  

 

 

자바 
 == 와 equals

자바에서 동일성을 체크하는 방법에는 == 와 equals 가 사용 되는데요. 

값 타입에는 자연스러운 등호와 같지만 둘 다  참조 타입에 관해서는 객체의 주소가 같은지 비교합니다. 

 

즉  위 그림처럼  == 와  equals  (java.lang.Object) 둘 다  동일한 주소를 가르켜야 같다고 판정합니다. 

 

 

위와 같이 가르키는 객체는 달라도 각 객체가 담고있는 값(들) 이 같으면 같다고 만들 수 도 있는데..

equals 의  오버라이딩을 이용해서   동일성 체크방법을 바꿀 수  있습니다.

String a = new String("test")

String b = new String("test")

a.equals(b)

위의 a 와 b 를 비교하면 true 가 되는것은 equals 를 String 객체에서 오버라이딩 하고 있기 때문입니다.

a.equals(b)  // true

a == b      // false

입니다. 자바에서 == 는 무조건 동일한 객체를 바라보고 있어야 true 가 됩니다. 

결국 자신이 만든 객체를 String 처럼  객체가 가진 내용으로 동일성 여부를 판단하려면
반드시 equals 를 오버라이딩을  해 주고 equals 를 사용해야 합니다.

 

hashCode

 " equals 메소드를 오버라이드 하는 모든 클래스는 반드시 hashCode 메소드도 오버라이드 해야 한다"

  hashCode 을 오버라이드 하지 않으면 HashMap 과 HashSet 같은 모든 해쉬기반 컬렉션들을 사용할때
  문제가 생깁니다. 

 

eqauls 함수 작성법

중요하다고 생각되는  세가지만 살펴보겠습니다. 더 자세한 내용과 팁은 effective java item 8,9 를 참고하세요.

* 동치 관계로 정의하자 (반사성/대칭성/추이성/일관성 을 지키자) . ( 상세 내용 다음 포스트에 ) 

* equals 메소드를 오버라이드 할 때는 hashCode 를 항상 오버라이드한다.

* equals 메소드의 인자 타입을 Object 대신 다른 타입을 사용하지 말자. 

public boolean equals (MyClass c) {

     ...

}

이렇게 하지 말라는 겁니다.

public boolean equals (Object o) {

     if (! (o instanceof MyClass))

     return false;

MyClass c = (MyClass) o;

...

}

이게 정석입니다.

인자를 MyClass 로 하면 equals 를 오버라이딩 하는게 아니라 오버로딩하기때문에 상위의 기존 eqauls 메소드는 그대로 남아있게되고, 여러 상황에서 상위 equals 메소드를 사용하게 됩니다.

 

스칼라 

 

자바 프로그램을 작성할 때 초보자가 자주 빠지는 함정이 equals 를 오버라이드 한 후 비교해야 하는
두 객체를 == 로 비교 하는 것입니다. 

스칼라의 경우 객체가 같은지 비교하는 동일성 연산자가 eq 라고 있는데 자주 사용되지는 않습니다.

'x eq y'  경우  같은 객체를 가르킬 때 만 true 입니다.

스칼라에서는 == 동일성을 '자연스러운' 동일성을 위해 예약해 뒀습니다.  (코틀린도 마찬가지) 

값 타입의 경우는 자바와 마찬가지로 값을 비교하구요

참조 타입에 관해서 스칼라의 == 는 자바의 equals 와 같습니다. 

즉 새로운 타입에 관해 equals 메소드를 오버라이드하면 자바와는 다르게 == 의 의미도 재정의가 됩니다.

Any 클래스에 있는 equals 는 오버라이드 하지 않는 경우 자바의 == 와 같은  객체 동일성을 사용합니다.

결국 equals , == , eq 는 기본적으로 같습니다. (동일 주소인지 비교) 하지만 equals 를 오버라이드하면 자바는 그것만 재정의 되는 반면, 스칼라는 == 도 함께 재정의 된다가 중요 포인트입니다. 

 

그림으로 간단히 풀어 보겠습니다.

 eq

 

 

자바의 == 처럼 같은 객체(주소) 를 가르켜야 같다고 인정됩니다.

 

 == 와 equals

 

만약 equals 를 오버라이딩 하는 경우에는 위의 그림 처럼 객체의 내용으로 비교 가능합니다.

이때 == 도 자연스럽게 변경됩니다. 따라서 본능적(?) 으로 == 를 객체 비교하는데 사용해도 문제가 없어집니다. 

* equals 를 오버라이드 안하면  equals 와 == 는 둘 다 객체의 주소를 가르켜야 같다고 판정됩니다.

 

equals 의 함정 

1. equals 선언 시 잘못된 시그니처를 사용하는 경우 

class Point (val x : Int , val y: Int) { ... } 라는 클래스의 eqauls 를 정의해본다고 하자.

def equals(other : Point) : Boolean = this.x == other.x && this.y == other.y 

뭐 너무 단순해서 문제가 생길 여지가 없어보인다.

val p1,p2 = new Point(1,2) 

p1.equals(p2)  // true 

val q = new Point(2,2)

p1 equals q  // false

 

잘 작동한다~~

하지만 ~!! 컬렉션에 넣기 시작하면서 문제가 발생한다. 

 

val s = HashSet(p1)

s.contains(p2)  // false !!

엥~ 둘은 같은데 HashSet에 존재하지 않다네요..  아래 예를 일단 보시죠.

val p2a : Any = p2 

p1 equals p2a  // false

타입을 Point 에서 Any 로 바꾸어서 equals 를 사용하니 다르다고 하네요?

사실 앞에서 정의한 equals 는 표준 메소드인 equals 메소드를 오버라이드하지 않고 그냥 오버로딩합니다.

그 상태에서 HashSet 의 contains 메소드는 제네릭 집합에 작용하기 때문에, Object 에 있는 더 일반적인 equals 를 호출하기때문에 false 가 되었던 것입니다.

다음과 같이 수정해야죠.

override def equals(other Any) = other match {

case that : Point => this.x == other.x && this.y == other.y

case _ => false

}

근데 이것만으로 충분하지 않습니다.. . (-.-;;)  

 

2. equals 를 변경하면서 hashCode 는 그대로 놔두는 경우

val s = HashSet(p1)

s.contains(p2)  // false !!

다시 contains 를 해보면 그래도 false 가 나옵니다. (true 가 나올수도..)

이랬다 저랬다 하는 이유는

HashSet 은 원소들을 해시코드에 따른 '해시 버킷' 에 담게 됩니다. 

contains 는 먼저 '해시 버킷' 을 결정한다음에 , 그 버킷안에서 원소들을 찾게 되는데

hashCode를 재정의 안하면 객체 주소를 적당히 바꿔서 해시 코드로 만듭니다. 
결국  
p1 과 p2 는 객체 주소가 다르기 때문에  다른 해시코드를 만들고 다른 '해시버킷' 에 담길 가능성이
커지죠. 
그래서 못찾게 되는겁니다. 결국 hashCode 를 동일하게 만들어 주는 작업이 필요합니다.

hashCode 를 구현 해 보겠습니다.

class Point(val x: Int, val y: Int){

override def hashCode = 41 * (41 + x) + y

...

}

이렇게 해주면 p1 과 p2 는 동일 해시코드를 가지고 있기때문에 동일 '해시버킷' 에 담기게되서 

contains 는 잘 작동하게 됩니다.  (hashCode 를 어떻게 만드느냐도 중요한 주제입니다만 .패스~) 

 

3. equals 를 변경 가능한 필드의 값을 기준으로 정의한 경우

 class Point (var x : Int , var y: Int) { ... } 라는 클래스의 eqauls 를 정의해본다고 하자. ( var 로 정의됨) 

val p  = new Point(1,2) 

val s = HashSet(p)

s.contains(p) // true 이다.

여기서 

p.x +=1 로 바꾸어보자.

s.contains(p) // false !

왜 이럴까요?

콜렉션안의 저장되있는 '해시버킷' 의 위치와 1 더 해진 후 계산되는 '해시버킷' 의 위치가 달라져서

시야에서 사라져 버렸기 때문입니다. 

equals 와 hashCode 가 변경 가능한 상태에 의존하면 잠재적으로 문제를 야기할 수 있음을 인지합시다.

 

4. equals 를 동치 관계로 정의 하지 않은 경우 

 동치 관계로 정의하자 (반사성/대칭성/추이성/일관성 을 지키자) . ( 상세 내용 다음 포스트에

 

좋은 equals 와 hashCode 를 만드는 방법 

class Rational(n : Int, d : Int ) {

	val numer = Iif (d<0) -n else n) / g

	val denom = d.abs / g 

	...

	override def equals (other : Any) : Boolean = 

	other match {

		case that: Rational => (that canEqual this) && numer == that.numer && denom == that.denum

		case _ => false

	}


    def canEqual(other : Any) : Boolean = other.isInstanceOf (Rational)

	override def hashCode: Int = 41 * (41 + number) + demon


...

}

 

가.  equals 를 final이 아닌 클래스에서 오버라이딩하면 , canEqual 메소드를 만들자.

나.  canEqual 메소드는 인자 객체가 현재 클래스라면 true 로 하자. (canEqual 내용은 다음 포스트에) 

다.  equals 메소드 정의에서 전달 받는 파라미터의 타입은 반드시Any 이다.

라.  equals 메소드의 본문을 match 표현식을 하나 사용해 작성하라. 

마.  equals 식에서 첫 번째 대안 부분에는 클래스의 타입과 같은 타입 패턴을 선언한다. case that: Rational =>

바.  이 case 문의 본문에, 객체들이 같기 위해 만족해야 하는 조건을 논리곱(&&) 을 사용해 작성하라.

     만약 오버라이드 하는 equals 가 AnyRef 에서 온것이 아니라면, 슈퍼클래스의 equals 를 호출하도록 한다.

     super.equals(that) && 

사. match 문의 두 번째 case 에는 와일드 카드 패턴을 사용해 false 반환 . case - => false

아. hashCode 의 경우 equals 메소드에서 동일성 계산에 사용했던 모든 필드('중요한필드') 를 포함시켜라. 

    전체 객체의 해시 코드를 계산하기 위해, 첫 필드의 해시 코드에 41을 더한 결과에 1 을 곱하고~~반복.

 

스칼라 요약 

 

* equals 를 오버라이딩하면 == 도 자동으로 적용

* hashCode 를 반드시 함께 오버라이딩 

* 타입 시그니처 주의

* 변경 가능한 상태를 가진 값으로 hashCode 를 계산해서는 안된다.

* 클래스가 final 이 아니라면 canEqual 메소드도 정의해야한다.

* 비교 가능한 클래스를 정의할 때 케이스 클래스로 만드는 것도 좋다.

 

 

Comments