30.1 스칼라에서의 동일성

- 자바의 동일성

: == 연산자는 값 타입에 대해서는 자연스러운 등호와 같고 참조 타입의 경우에는 동일한 객체인지(주소가 같은지)를 비교한다.

: equals 메소드는 참조 타입에 대한 표준 동일성 검사를 제공하는 사용자 정의 메소드이다.

=> eqauls로 비교해야만 하는 두 객체를 ==를 사용해 비교해서 발생할 수 있는 함정

Ex. 자바에서 두 문자열 x와 y를 x==y로 비교하면 x와 y의 문자들이 서로 같을지라도 false가 나올 수 있다.

=> == 기호가 항상 자연스러운 동일성을 나타내지 못하는 문제가 있다.

 

- 스칼라의 동일성

: x eq y 연산자는 x와 y가 같은 객체를 가리킬 때만 true

: == 연산자는 값 타입의 경우 값 비교, 참조 타입의 경우 자바 equals와 같다.

: Any 클래스에 있는 equals를 상속받아 오버라이드하면 ==의 의미를 재정의 가능

: 오버라이드 하지 않은 경우 equals는 자바의 ==와 마찬가지로 객체 동일성을 사용한다. => equals는 기본적으로 eq와 같다.

final def == (that: Any): Boolean =
  if (null eq this) {null eq that} else {this equals that}

 

30.2 동일성 비교 메소드 작성

: 동일성은 다른 많은 것의 근간이기 때문에 equals 메소드에 오류가 있다면 큰 문제

" 두 원소 elem1과 elem2가 같은 경우, elem1 equals elem2가 true "
var hashSet: Set[C] = new collection.immutable.HashSet
hashSet += elem1
hashSet contains elem2    // returns false!

- equlas 구현 시 일관성이 없는 동작을 야기할 수 있는 네 가지 일반적인 함정

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

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

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

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

 

< 함정 #1 : equals 선언 시 잘못된 시그니처를 사용하는 경우 >

class Point(val x: Int, val y: Int) {       // 점을 표현하는 클래스
  ...
  def equals(other: Point): Boolean =             // 잘못된 equals 정의
    this.x == other.x && this.y == other.y
}

scala> val p1, p2 = new Point(1, 2)
p1: Point = Point@79f0ec

p2: Point = Point@1b8424e


scala> val q = new Point(2, 3)
q: Point = Point@d990db

scala> p1 equals p2
res0: Boolean = true

scala> p1 equals q
res1: Boolean = false

scala> import scala.collection.mutable._
import scala.collection.mutable._

scala> val coll = HashSet(p1)
coll: scala.collection.mutable.HashSet[Point] =
Set(Point@79f0ec)

scala> coll contains p2      // Point를 컬렉션에 넣으면 문제가 생긴다.
res2: Boolean = false


scala> val p2a: Any = p2    // Point 타입이 아닌 Any 타입
p2a: Any = Point@1b8424e

scala> p1 equals p2a      // Any의 equals 호출
res3: Boolean = false

: 위에 정의한 equals는 표준 메소드인 equals와 타입이 다르기 때문에 오버라이드하지 않는다. => 기존 Any의 eqauls 호출

* Any에 있는 equals : def equals(other : Any) : Boolean

" 더 나은 정의, 하지만 여전히 완전하지는 않다. "

override def equals(other: Any) = other match {
  case that: Point => this.x == that.x && this.y == that.y
  case _ => false
}

: 이와 비슷한 함정으로 ==를 잘못된 시그니처로 정의하는 것인데 ==는 final 메소드이기 때문에 재정의하면 오류

 

< 함정 #2 : equals를 변경하면서 hashCode는 그대로 놔둔 경우 >

scala> val p1, p2 = new Point(1, 2)
p1: Point = Point@67e5a7
  p2: Point = Point@1165e21

scala> HashSet(p1) contains p2
res4: Boolean = false        // equals는 재정의했지만 hashCode는 재정의하지 않아서

: HashSet => 원소들이 해시 코드에 따른 해시 버킷에 들어간다. => contains는 먼저 들여다볼 해시 버킷을 결정한 다음, 주어진 원소를 그 버킷 안의 모든 원소와 비교한다. => hashCode를 재정의 X, AnyRef의 hashCode가 사용된다. => p1과 p2의 해시 코드가 달라진다. => hashCode와 equals를 항상 함께 정의해야 한다.

 

* 만약 equals 메소드로 따졌을 때 두 객체가 같다면, hashCode를 각 객체에 호출한 결과도 같은 정수 값을 만들어야 한다.

class Point(val x: Int, val y: Int) {
  override def hashCode = (x,y).##    
  override def equals(other: Any) = other match {
    case that: Point => this.x == that.x && this.y == that.y
    case _ => false
  }
}

: ## 메소드는 기본 타입의 값, 참조 타입의 값, null 값에 작용하는 해시 코드를 계산해주고 컬렉션이나 튜플에 대해 ##을 호출하면 컬렉션에 들어 있는 모든 원소에 대해 적당한 해시 코드를 계산해준다.

 

< 함정 #3 : equals를 변경 가능한 필드의 값을 기준으로 정의한 경우 >

" 필드를 var로 바꾼 Point 클래스 "

class Point(var x: Int, var y: Int) {
  override def hashCode = (x,y).##
  override def equals(other: Any) = other match {
    case that: Point => this.x == that.x && this.y == that.y
    case _ => false
  }
}

 

scala> val p = new Point(1, 2)
p: Point = Point@6bc

scala> val coll = HashSet(p)
coll: scala.collection.mutable.HashSet[Point] =
Set(Point@6bc)

scala> coll contains p
res5: Boolean = true

scala> p.x += 1

scala> coll contains p     // x 필드 값을 변경했기 때문에 p의 버킷을 잘못 계산
res7: Boolean = false

scala> coll.iterator contains p
res8: Boolean = true

: equals와 hashCode가 변경 가능한 상태에 의존하면 잠재적으로 문제를 야기할 수 있다. => 객체의 최신 상태를 감안하고 비교가 필요하다면 이름을 equals라고 지어서는 안 된다. => hashCode 재정의는 없애고 equals는 equalContents 등의 다른 이름을 붙여 기본 equals와 hashCode를 상속한다. => x 필드의 값을 바꿔도 여전히 coll 내의 p가 어디에 있는지 찾을 수 있다.

 

< 함정 #4 : equals를 동치 관계로 정의하지 않는 경우 >

: equals 메소드는 null이 아닌 객체에 대해 동치 관계여야 한다.

1. 반사성 : 널이 아닌 값 x에 대해 x.equals(x)는 true를 반환해야 한다.

2. 대칭성 : 널이 아닌 값 x,y에 대해 x.equals(y)가 true를 반환하면 y.equals(x)도 true를 반환해야 한다. 또한 y.equals(x)가 true이면 x.equals(y)도 true여야 한다.

3. 추이성 : 널이 아닌 값 x,y,z에 대해 x.equals(y)가 true이고 y.equals(z)가 true를 반환하면 x.equals(z)도 true를 반환해야 한다.

4. 일관성 : 널이 아닌 값 x,y에 대해 x.equals(y)를 여러 번 호출해도 x나 y 객체에 있는 정보가 변경되지 않은 이상 계속 true나 false 중 한 값을 일관되게 반환해야 한다.

5. 널이 아닌 값 x에 대해 x.equals(null)은 false를 반환해야 한다.

 

: 위에서 만든 Point 클래스는 동치 관계를 만족하나 서브 클래스를 고려해야 하는 경우 문제가 생긴다.

object Color extends Enumeration {
  val Red, Orange, Yellow, Green, Blue, Indigo, Violet = Value
}

class ColoredPoint(x: Int, y: Int, val color: Color.Value) extends Point(x, y) {
  override def equals(other: Any) = other match {
    case that: ColoredPoint => 
      this.color == that.color && super.equals(that)
    case _ => false
  }
}

 

scala> val p = new Point(1, 2)
p: Point = Point@6bc

scala> val cp = new ColoredPoint(1, 2, Color.Red)
cp: ColoredPoint = ColoredPoint@6bc

scala> p equals cp
res9: Boolean = true

scala> cp equals p                          // p가 ColoredPoint가 아니기 때문에 => 대칭 X
res10: Boolean = false

 

" 대칭성이 아니기 때문에 생기는 문제 " : p와 cp가 같은 점인데도 한 contains는 성공하고 다른 하나는 실패한다.

scala> HashSet[Point](p) contains cp
res11: Boolean = true

scala> HashSet[Point](cp) contains p
res12: Boolean = false

 

: 대칭 문제를 해결하는 방법은 관계를 더 엄격하게 만들거나 더 느슨하게 만들면 된다.

 

< 더 느슨하게 만들기 >

: 두 객체 x,y의 동일성을 비교할 때 x에 y를 비교하거나, y에 x를 비교해 둘 중 하나 이상이 true이면 성립하게 한다.

class ColoredPoint(x: Int, y: Int, val color: Color.Value) 
    extends Point(x, y) { // Problem: equals not transitive

  override def equals(other: Any) = other match {
    case that: ColoredPoint =>
      (this.color == that.color) && super.equals(that)
    case that: Point =>
      that equals this
    case _ =>
      false
  }
}

: 대칭적으로 만들 수 있지만 추이적이지 않는다.

" 추이성이 깨진 예 "

scala> val redp = new ColoredPoint(1, 2, Color.Red)
redp: ColoredPoint = ColoredPoint@6bc

scala> val bluep = new ColoredPoint(1, 2, Color.Blue)
bluep: ColoredPoint = ColoredPoint@6bc


scala> redp == p
res13: Boolean = true

scala> p == bluep
res14: Boolean = true


scala> redp == bluep
res15: Boolean = false

 

< 더 엄격하게 만들기 >

: 클래스가 다른 객체는 아예 서로 다른 것으로 간주한다.

class Point(val x: Int, val y: Int) {
  override def hashCode = (x,y).##
  override def equals(other: Any) = other match {
    case that: Point => 
      this.x == that.x && this.y == that.y && 
      this.getClass == that.getClass                  // 실행 시점의 클래스가 자기 자신의 클래스와 일치하는 지 살펴본다
    case _ => false
  }
}

: Point 클래스의 인스턴스는 동일한 클래스의 다른 인스턴스와 오직 좌표가 같고 실행 시점 클래스가 동일한 경우에만 같다.

 

class ColoredPoint(x: Int, y: Int, val color: Color.Value) extends Point(x, y) {

  override def equals(other: Any) = other match {
    case that: ColoredPoint =>
      (this.color == that.color) && super.equals(that)
    case _ => false
  }
}

: 각기 다른 클래스에 속한 모든 객체 간의 비교가 실패하기 때문에 대칭성과 추이성을 만족한다.

: 어떤 색이 있는 점이 그냥 단순한 점과 같아지는 일이 결코 없어 이런 정의가 너무 엄격하다고 볼 수도 있다.

scala> val pAnon = new Point(1, 1) { override val y = 2 }

: pAnonscala> val p = new Point(1,1)
p: Point = Point@6bb

scala> val pAnon = new Point(1, 1) { override val y = 2 }
pAnon: Point = $anon$1@6bc

scala> p equals pAnon              // p와 pAnon은 좌표 (1,2)를 가리키지만 pAnon은 Point를 상속한 이름 없는 클래스이기 때문에 false
res27: Boolean = false         

 

=> equals의 동치 관계를 지키면서 여러 단계의 클래스 계층 구조에 대해 동일성을 재정의할 방법으로 canEqual 메소드를 추가가 있다.

def canEqual(other: Any): Boolean

: other 객체가 (재)정의한 클래스의 인스턴스라면 true를 반환하며 아니면 false를 반환한다. equals는 이 함수를 호출해서 객체가 양방향으로 비교 가능한지 검사한다.

class Point(val x: Int, val y: Int) {
  override def hashCode = (x,y).##
  override def equals(other: Any) = other match {
    case that: Point =>
      (that canEqual this) &&
      (this.x == that.x) && (this.y == that.y)
    case _ =>
      false
  }
  def canEqual(other: Any) = other.isInstanceOf[Point]
}

 

class ColoredPoint(x: Int, y: Int, val color: Color.Value) extends Point(x, y) {

  override def hashCode = (super.hashCode, color.hashCode).##
  override def equals(other: Any) = other match {
    case that: ColoredPoint =>
      (that canEqual this) &&
      super.equals(that) && this.color == that.color
    case _ =>
      false
  }
  override def canEqual(other: Any) =
    other.isInstanceOf[ColoredPoint]
}

: canEqual을 통해 양방향으로 비교해서 대칭성와 추이성 만족

scala> val p = new Point(1, 2)
p: Point = Point@6bc

scala> val cp = new ColoredPoint(1, 2, Color.Indigo)
cp: ColoredPoint = ColoredPoint@11421

scala> val pAnon = new Point(1, 1) { override val y = 2 }
pAnon: Point = $anon$1@6bc

scala> val coll = List(p)
coll: List[Point] = List(Point@6bc)

scala> coll contains p 
res16: Boolean = true

scala> coll contains cp
res17: Boolean = false

scala> coll contains pAnon     // pAnon이 canEqual을 오버라이딩하지 않아서 Point의 canEqual 적용
res18: Boolean = true

: canEqual 구현 여부를 통해 서브 클래스를 만든 프로그래머가 해당 서브 클래스의 인스턴스가 슈퍼 클래스의 인스턴스와 같을 수 있는지 여부를 결정할 수 있다.

: equals 메소드 case 내부에서 실행 시간에 Point 클래스인지 ColoredPoint인지 비교하는 방식을 사용하면 Point 클래스 equals를 ColoredPoint equals 메소드로 대체 불가능 => 리스코프 치환 원칙 위배 => 하지만 ColoredPoint와 Point는 기본적으로 같지 않다는 계약하에 만들어졌기 때문에 위배는 상관이 없음

 

30.3 파라미터화한 타입의 동일성 정의

trait Tree[+T] {   // 이진 트리를 표현하는 추상 클래스
  def elem: T
  def left: Tree[T]
  def right: Tree[T]
}

object EmptyTree extends Tree[Nothing] {
  def elem =
    throw new NoSuchElementException("EmptyTree.elem")
  def left =
    throw new NoSuchElementException("EmptyTree.left")
  def right =
    throw new NoSuchElementException("EmptyTree.right")
}

class Branch[+T](
                  val elem: T,
                  val left: Tree[T],
                  val right: Tree[T]
                ) extends Tree[T]

- equals와 hashCode 추가

: 동일성 관련 메소드는 추상 클래스를 구현하는 각 서브클래스에서 별도로 구현한다고 했을 시 Tree자체의 경우 할 게 없다.

: EmptyTree는 AnyRef에서 상속한 equals와 hashCode만으로도 충분(참조 동일성)

: 어떤 두 Branch 값들이 서로 같으려면 elem, left, right 필드가 같아야 한다.

class Branch[T](
                 val elem: T,
                 val left: Tree[T],
                 val right: Tree[T]
               ) extends Tree[T] {

  override def equals(other: Any) = other match {
    case that: Branch[T] => this.elem == that.elem &&
                                                            this.left == that.left &&
                                                                  this.right == that.right
    case _ => false
  }
}


$ fsc -unchecked Tree.scala         // unchecked 에러 발생
Tree.scala:14: warning: non variable type-argument T in type
pattern is unchecked since it is eliminated by erasure
case that: Branch[T] => this.elem == that.elem &&
  ^

: 컴파일러는 타입 파라미터의 원소 타입을 제거하기 때문에 실행 시점에 Branch[T] 타입인지 검사하지 못하고 Branch 일종인지만 검사

그런데 원소 타입이 각기 다른 두 Branch가 같을 수도 있기 때문에 타입 파라미터의 원소 타입을 검사할 필요가 없을 수도 있다.

 

scala> val b1 = new Branch[List[String]](Nil,
  |      EmptyTree, EmptyTree)
b1: Branch[List[String]] = Branch@158c7fa

scala> val b2 = new Branch[List[Int]](Nil,
  |      EmptyTree, EmptyTree)
b2: Branch[List[Int]] = Branch@1f4a968

scala> b1 == b2           // Branch의 원소 타입을 검사했다면 false
res19: Boolean = true

: 이 클래스 비교 결과로 나올 수 있는 결과 중 어느 쪽이 자연스러운가에 대해 이견이 있을 수 있다. 결국 클래스를 어떻게 표현할 수 있는 가는 마음 속에 가지고 있는 모델에 달려 있다.

: 타입 파라미터가 컴파일 시점에만 존재해야 한다고 생각하면 b1과 b2는 Branch 값이 같다고 간주하는 게 자연스럽고 타입 파라미터가 객체의 값의 일부라고 생각하면 그 둘이 다르다고 생각하는 게 자연스럽다.

: 스칼라는 타입 소거 모델을 채택했고 타입 파라미터가 실행 시점에 존재하지 않기 때문에 b1과 b2를 같다고 생각하는 게 자연스럽다.

=> unchecked 에러를 없애려면?

case that: Branch[t] =>       //   case that: Branch[_] =>

  (that canEqual this) && 

  this.elem == that.elem &&   
  this.left == that.left &&
  this.right == that.right

: 소문자와 _ 패턴 매치는 어떤 타입의 Branch 값도 매치시킬 수 있다. => t, _ 타입 파라미터는 Branch의 알려지지 않은 원소 타입 표현

 

def canEqual(other: Any) = other.isInstanceOf[Branch[_]]

: Branch[_]는 메소드의 타입 파라미터로서 와일드 카드 타입의 축약 표현, 즉 알려지지 않은 부분이 안에 있는 타입을 의미

: hashCode는 모든 필드의 hashCode 값을 서로 조합

override def hashCode: Int = (elem, left, right).##

 

30.4 equals와 hashCode 요리법

< equals 요리법 >

1. equals를 final이 아닌 클래스에서 오버라이드한다면 canEqual 메소드를 만들어야 한다 .

: equals를 AnyRef에서 상속받는다면 canEqual을 새로 정의, 그렇지 않다면 canEqual을 오버라이드

: canEqual에 전달할 객체의 타입은 Any여야 한다.

 

2. canEqual 메소드는 인자 객체가 현재 클래스(그 canEqual을 정의한 클래스)라면 true, 그렇지 않다면 false 반환

 

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

 

4. equals 메소드의 본문을 match 표현식을 하나 사용해 작성하라. match의 셀렉터는 equals가 넘겨받은 객체여야 한다.

 

5. match 식의 첫 번째 case는 equals 메소드 정의가 있는 클래스의 타입과 같은 타입 패턴을 선언해야 한다.

 

6. 객체들이 같기 위해 만족해야 하는 조건을 논리적 곱(&&)을 사용해 작성한다. 만약 오버라이드하는 equals가 AnyRef에서 온 것이 아니라면, 슈퍼 클래스의 equals를 호출하도록 한다.

 

7. 클래스에서 canEquals를 정의하는 경우라면 equals가 받은 인자에 대한 canEqual을 호출해야 한다.  equals를 재정의한 것을 다시 오버라이드하는 경우 super.equals를 호출하지 않는다면 꼭 canEqual 호출을 해야 한다. super.equals를 호출한다면 그 함수가 canEqual을 통한 검사를 수행한다.

 

8. 동일성과 관련 있는 모든 필드에 대해 this의 필드들이 equals의 인자로 들어온 객체에 있는 필드들과 같은지 검사

 

9. 매치문의 두 번째 case는 와일드 카드 패턴을 사용해 false를 반환

 

< hashCode 요리법 >

: hashCode 계산 시 equals 메소드에서 동일성 계산에 사용했던 모든 필드를 포함시켜라. 모든 중요한 필드가 들어있는 튜플을 만들고 그 튜플에 대해 ##을 호출하라.

override def hashCode: Int = (a,b,c,d,e).##    // a,b,c,d,e 주요 필드에 대한 해시 코드

: equals 메소드가 super.equals(that)을 호출한다면 hashCode 계산에서도 super.hashCode를 호출

override def hashCode: Int = (super.hashCode, a,b,c,d,e).##

: 필드 중 하나가 컬렉션이라면 그 필드의 해시 코드를 컬렉션에 들어 있는 모든 원소를 기반으로 계산해야할 수도 있다. 각 컬렉션 클래스에는 이미 전체 원소를 염두에 둔 equals와 hashCode가 오버라이드되어 있으므로 그냥 필드의 hashCode를 호출하면 된다. 하지만 Array는 그렇지 않아서 각 원소에 hashCode를 호출하거나 java.util.Arrays 싱글톤 객체에 있는 hashCode 메소드에 배열을 인자로 넘겨서 해시 코드를 계산하면 된다.

 

: 해시 코드 계산이 프로그램 성능에 악영향을 끼친다면 해시 코드 캐시를 고려한다. 객체가 변경 불가능하다면 해시 코드를 객체 생성 시 계산해서 필드에 저장할 수 있다.

override val hashCode: Int = (super.hashCode, a,b,c,d,e).##     // def를 val로 바꾸면 된다.

 

 

 

+ Recent posts