티스토리 뷰

반응형

Object 클래스는 객체를 만들 수 있는 구체 클래스면서 중요한 특징으로 모든 클래스의 부모 클래스라는 점이 있다는 것을 기억해야 한다.

 

Object 클래스에는 equals, hashCode, toString, clone 등 몇 가지 메서드가 포함되어 있는데 이러한 메서드들은 모두 재정의를 염두에 두고 제작된 메서드이다. 따라서 이러한 메서드들을 재정의할 때 지켜야 하는 일반 규약들이 명확하게 정의되어 있다. 이러한 일반규약은 당연히 지켜진다고 가정되기 때문에 지키지 않았을 때 HashSet, HashMap과 같은 것에서 오동작을 발생시킬 수 있다.

 

이번 포스팅에서는 Object 클래스에 포함되어 있는 equals, hashCode, toString, clone 을 재정의할 때 지켜야할 일반규약에 대해 설명하려고 한다.

[아이템 10] equals는 일반규약을 지켜 재정의하라


equals 메서드는 간단하게 재정의할 수 있을 것처럼 보이지만 곳곳에 함정이 존재한다. 이러한 함정에 걸리지 않을 가장 좋은 방법은 equals 메서드를 재정의하지 않는 것이다.

 

equals 메서드를 재정의해야 하는 순간은 언제일까? 바로 두 개의 객체에 대한 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 적합하게 재정의되지 않았을 때다.

 

equals 메서드를 재정의하지 않을경우 기본적으로 hashCode 메서드를 통해서 비교하기 때문에 객체 자기자신을 제외하고는 모두 다르다고 판단하게 된다. 따라서 논리적 동치성 확인을 위해서는 equals 메서드를 반드시 재정의해야 한다.

 

그러면 equals 메서드를 재정의해야 할 때 지켜야하는 일반규약들을 살펴보자.

  • 반사성(reflexivity) : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true 이다.
    즉, 자기자신에 대해서는 반드시 true를 반환해야 한다.
  • 대칭성(symmetry) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)는 true 이다.
    즉, 비교하는 순서를 바꿔도 동일한 결과를 반환해야 한다.
  • 추이성(transitivity) : null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고, y.equals(z)가 true면 x.equals(z)는 true 이다.
    즉, 삼단논법을 지켜야 한다.
  • 일관성(consistency) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 여러 번 반복해서 호출해도 항상 동일한 결과가 반환되어야 한다.
  • Not Null : null이 아닌 모든 참조 값 x에 대해 x.equals(null)은 false이다.
    NullPointException을 발생시키는 코드 조차 허용하지 않는다. null인지 조건문을 통해서 확인하는 것보다 instanceof 연산자를 통해 확인하는게 더 깔끔하고 형변환에 있어서도 유리하다. (아래 코드 참조)

기본적으로 반사성과 대칭성은 간단하기 때문에 넘어가도록 한다. (사실 어기는게 더 어려울수도 있다)

 

추이성도 간단하지만 의외로 어기기 쉽다고 한다. 예를 들어 아래와 같은 상황을 보자.

public class Point {
    private final int x;
    private final int y;
    
    public Point(int x, int y) {
    	this.x = x;
        this.y = y;
    }
    @Override
    public boolean equals(Object o) {
    	if (!(o instanceof Point))
        	return false;
        Point p = (Point) o;
        return p.x == x && p.y == y;
    }
}

public class ColorPoint extends Point {
    private final Color color;
    
    public ColorPoint(int x, int y, Color color) {
    	super(x, y);
        this.color = color;
    }
    @Override
    public boolean equals(Object o) {
    	if(!(o instanceof ColorPoint))
        	return false;
        return super.equals(o) && ((ColorPoint o).color == color;
    }
}

언뜻 보기엔 정상적인 코드처럼 보인다. 

 

하지만 Point 객체와 ColorPoint 객체를 비교한 결과와 순서를 바꿔 비교한 결과가 다를 수 있다. Point의 equals는 색상을 무시하고, ColorPoint의 equals는 입력 매개변수의 클래스 종류가 다르다며 매번 false를 반환하게 된다.

 

한마디로 equals 메서드를 호출하는 객체의 클래스가 어느 것이냐에 따라서 다른 equals 메서드가 호출될 수 있다. 이 과정에서 일반규약의 대칭성과 추이성이 깨지게 된다.

 

임시 방편으로 ColorPoint 클래스의 equals 메서드를 아래와 같이 수정해보자.

@Override
public boolean equals(Object o) {
    if(!(o instanceof Point))
    	return false;
    if(!(o instanceof ColorPoint))
    	return o.equals(this);
    return super.equals(o) && ((ColorPoint) o).color == color;
}

이 코드의 의미는 ColorPoint.euqals(Point)일 때는 color를 무시하고 비교하겠다는 뜻이다. 이렇게 수정할 경우 대칭성은 지켜질 수 있겠지만 아래 코드에서 볼 수 있듯이 여전히 추이성은 깨져있는 상태다.

ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p2 = new ColorPoint(1, 2, Color.BLUE);

p1.equals(p2); // TRUE
p2.equals(p3); // TRUE  (color를 무시하기 때문에)
p1.equals(p3); // FALSE

 

이러한 문제는 모든 객체 지향 언어의 동치관계에서 나타나는 근본적인 문제이다. 이러한 문제로 구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 괜찮은 우회 방법이 하나 있다.

 

그 방법은 상속 대신 컴포지션을 사용하는 것이다. 개선된 코드를 살펴보자.

public class ColorPoint {
    private final Point point;
    private final Color color;
    
    public ColorPoint(int x, int y, Color color) {
    	point = new Point(x, y);
        this.color = Objects.requiredNonNull(color);
    }
    public Point asPoint() {
    	return point;
    }
    @Override
    public boolean equals(Object o) {
        if(!(o instanceof ColorPoint))
        	return false;
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
}

이러한 형태는 자바 라이브러리에서도 종종 사용된다. 실제로 Spring Security를 사용해서 로그인 시스템을 구현할 때 UserContext 클래스에서 위와 같은 코드를 사용한 경험이 있다.

 

위 포스팅은 컴포지션에 대한 지식이 없을 때 작성했을 때라는걸 고려해서 읽도록 하자. (나중에 수정할 예정)

 

그러면 지금까지의 내용을 종합해 올바른 equals 메서드를 재정의하기 위한 방법을 단계별로 살펴보자.

  1. == 연산자를 사용해 입력이 자기 자신인지 확인한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
  3. 입력을 올바른 타입으로 형변환한다.
  4. 입력 객체와 자기 자신이 대응되는지 확인한다.

이 과정을 통해서 equals 메서드를 재정의한 전형적인 예시를 살펴보며 다음 내용으로 넘어가도록 한다.

@Override
public boolean equals(Object o) {
    if (o == this)
    	return true;
    if (!(o instanceof PhoneNumber))
    	return false;
    PhoneNumber pn = (PhoneNumber) o;
    return pn.lineNum == this.lineNum && pn.prefix == this.prefix && pn.areaCode == this.areaCode;
}

[아이템 11] equals를 재정의하려거든 hashCode도 재정의하라


위에 소개한 제목이 곧 hashCode에 대한 일반 규약이다. 이 규약을 지키지 않을 경우 해당 클래스에 대한 객체로를HashMap, HashSet과 같은 컬렉션의 원소로 사용할 때 문제를 일으킬 수 있다.

 

세부적인 일반 규약을 살펴보자.

  1. hashCode 메서드를 반복해서 호출해도 항상 같은 결과를 반환해야 한다.
  2. equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.
  3. equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블 성능이 좋아진다.

이 중에서도 두 번째를 지키지 않았을 경우 문제가 발생할 수 있다. 

 

두 번째 규약을 지키지 않을 경우 같은 객체(실제로 같은 객체가 아니라 논리적으로 같은 객체 - equals 재정의)임에도 불구하고 hashCode가 다르게 나타나기 때문에 HashSet, HashMap과 같은 컬렉션에선 두 객체를 다른 객체로 판단하게 된다.

PhonNumber p1 = new PhoneNumber(707, 867, 5309);
PhonNumber p2 = new PhoneNumber(707, 867, 5309);
// equals 재정의를 통해 p1 == p2 가 TRUE

Map<PhoneNumber, String> map = new HashMap<>();
map.put(p1, "ch4njun");
System.out.println(map.get(p2)); // ch4njun을 기대할 것이다.

하지만 p1과 p2의 hashCode가 다르다고 판단하기 때문에 map에는 p2라는 Key값은 가지고 있지 않다. 따라서 null을 출력하게 될 것이다.

 

그러면 이러한 문제를 해결하기 위해 좋은 hashCode 메서드를 재정의하는 전형적인 방법에 대해서 살펴보자.

// 전형적인 hashCode 메서드
@Override
public int hashCode() {
    int result = Short.hashCode(areaCode);
    result = 31 * result + Short.hashCode(prefix);
    result = 31 * result + Short.hashCode(lineNum);
    return result;
}

// 한 줄짜리 hashCode 메서드 - 성능이 살짝 아쉽다.
@Override
public int hashCode() {
    return Objects.hash(lineNum, prefix, areaCode);
}

 

hashCode 메서드를 재정의할 때 성능을 높이고자 핵심필드를 생략하는 실수를 범해서는 안된다. 당장 속도는 빨라지겠지만 해시 품질이 떨어져 해시테이블의 선응을 심각하게 망가뜨릴 수 있기 때문이다.

AutoValue 프레임워크를 사용하면 멋진 equals와 hashCode를 자동으로 재정의해준다.

[아이템 12] toString을 항상 재정의하라


기본적으로 정의되어 있는 toString 메서드는 "클래스 이름@16진수로 표시한 해시코드"를 반환한다. toString의 일반 규약에 따르면 "간결하면서 사람이 읽기 쉬운 형태의 유익한 정보"를 반환해야 한다.

 

하지만 재정의하지 않은 toString 메서드는 간결하지만 그다지 유익한 정보를 반환한다고 볼 수 없다.

 

앞서 소개한 equals와 hashCode처럼 재정의하지 않는다고 큰 문제가 발생하는 것은 아니지만, toString을 잘 구현한 클래스는 사용하기에 훨씬 즐겁고, 그 클래스를 사용한 시스템은 디버깅하기 쉽다는 장점이 있다.

(println, printf, 문자열 +연산자, assert 구문에 넘길 때, 디버거가 객체를 출력할 때 자동으로 호출된다)

[아이템 13] clone 재정의는 주의해서 진행하라


Cloneable 인터페이스는 복제해도 되는 클래스임을 명시하는 용도의 마커 인터페이스이다. 하지만 아쉽게도 의도한 목적을 제대로 이루지 못하고 있다. 가장 큰 문제는 clone 메서드가 선언된 곳이 Cloneable 인터페이스가 아니라 Object 클래스라는 것이고, 그 마저도 접근제어자가 protected 라는 것이다.

 

하지만 Cloneable 인터페이스는 이러한 문제에도 불구하고 많이 사용되기 때문에 잘 알아둘 필요성이 있다.

Cloneable 인터페이스의 용도?

Object의 protected 메서드인 clone 메서드의 동작 방식을 결정한다. Cloneable 인터페이스를 구현한 클래스의 인스턴스에서 clone 메서드를 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출히면 CloneNotSupportedException을 던진다.

 

실무에서 Cloneable 인터페이스를 구현한 클래스는 clone 메서드를 public으로 제공하며, 사용자는 당연히 복제가 제대로 되리라 기대하게 된다. 하지만 이 기대를 만족시키려면 그 클래스와 모든 상위 클래스는 복잡하고, 강제할 수 없고, 허술하게 기술된 프로토콜을 지켜야만 하는데, 그 결과로 깨지기 쉽고, 위험하고, 모순적인 메커니즘이 탄생하게 된다. 생성자를 호출하지 않고도 객체를 생성할 수 있게 되는 것이다.

 

이러한 Cloneable/clone 방식은 구현하려는 상위 클래스가 이미 Cloneable 인터페이스를 구현한 경우나 배열의 경우에만 사용하는 것을 권장한다.

 

그럼 클래스의 상황별로 clone 메서드를 구현한 예시를 살펴보자.

// 가변 상태를 참조하지 않는 클래스용 clone 메서드 (참조 타입이 없다는 말이다)
@Override
public PhoneNumber clone() {
    try {
    	return (PhoneNumber) super.clone();
    } catch (CloneNotSupportedException e) {
    	throw new AssetionError();
    }
}

위 예시는 가장 간단하게 사용할 수 있는 clone 메서드이다. super.clone() 을 호출하는 과정은 간단하게 말해서 모든 인스턴스 변수에 대해서 대입연산자를 수행하는 과정이라고 이해하면 좋다. 가변 상태를 참조하지 않는(인스턴스 변수가 모두 일반 타입) 클래스는 위 clone 메서드를 사용하면 된다.

 

하지만, 가변 상태를 참조(참조 필드가 존재)하는 클래스의 경우 위 clone 메서드를 사용했을 때 심각한 문제가 발생할 수 있다. 복사된 객체와 기존 객체의 인스턴스 변수가 동일한 메모리를 참조할 수 있기 때문이다. 이러한 문제를 해결하기 위한 clone 메서드를 살펴보자.

(대입 연산자를 통해 참조필드를 복사하면 주소값이 복사되어 저장되기 때문이 이와 같은 현상이 발생한다.)

// 가변 상태를 참조하는 클래스용 clone 메서드
@Override
public Stack clone() {
    try {
    	Stack result = (Stack) super.clone();
        // 가변 상태에 대해서 재귀적인 clone 메서드 호출
        result.elements = elements.clone();
        return result;
    } catch (CloneNotSupportedException e) {
    	throw new AssetionError();
    }
}

elements는 배열이고 배열은 대입연산자를 통해서 복사할 경우 메모리 주소를 복사해 저장하기 때문에 결과적으로 복사된 객체와 원본 객체가 동일한 메모리를 참조하게 된다. 따라서 위 코드와 같이 재귀적으로 clone 메서드를 호출해 같은 메모리가 참조되지 않도록 복사를 해줘야 한다.

 

하지만 이러한 방법도 문제가 존재하는데 배열에 저장된 데이터가 일반 타입이 아니라 참조 타입인 객체가 있을 경우가 그렇다. elements.clone() 메서드를 호출해 재귀적으로 복사를 하더라도 복사된 배열과 기존 배열에 저장된 객체는 동일한 객체이기 때문이다. 이러한 문제를 해결하기 위한 clone 메서드를 살펴보자.

// 복잡한 가변 상태를 갖는 클래스용 clone 메서드
private Entry deepCopy() {
    Entry result = new Entry(key, value, next);
    for (Entry p = result; p.next != null; p = p.next)
    	p.next = new Entry(p.next.key, p.next.value, p.next.next);
    return result;
}

@Override
public HashTable clone() {
    try {
    	HashTable result = (HashTable) super.clone();
        result.buckets = new Entry[bucekts.lenght];
        for (int i = 0; i < buckeks.lenght; ++i)
            if (buckets[i] != null)
            	result.buckets[i] = buckets[i].deepCopy();
        return result;
    } catch (CloneNotSupportedException e) {
    	throw new AssetionError();
    }
}

위 코드와 같이 배열에 저장된 객체를 하나씩 deepCopy 해 배열에 저장된 객체까지 정확하게 복사해줘야 문제가 발생하지 않는다. 생각보다 꽤 많이 발생하는 케이스인 것 같아 기억해두는게 좋을듯 하다.

 

보다시피 많이 복잡하다... 자세한 내용은 아래 블로그를 참고하면 좋을 것 같다!

https://javabom.tistory.com/15

 

아이템[13] - clone 재정의는 주의해서 진행하라

클래스에서 clone을 재정의 하기위해선 해당 클래스에 Cloneable 인터페이스를 상속받아 구현하여야 한다. 그런데 정작 clone 메소드는 Cloneable 인터페이스가 아닌 Object에 선언되어있다. Cloneable 인터

javabom.tistory.com

복사 생성자, 복사 팩토리 메서드

// 복사 생성자
public Yum(Yum yum) { ... }

// 복사 팩토리 메서드
public static Yum cloneInstance(Yum yum) { ... }

복사 생성자와 그 변형인 복사 팩토리 메서드는 Cloneable/clone 방식보다 나은 면이 많다.

 

언어 모순적이고 위험천만한 객체 생성 메커니즘(생성자를 사용하지 않고 객체 생성)을 사용하지도 않으며, 엉성하게 문서화된 규약에 기대지 않고, 정상적인 final 필드 용법과도 충돌하지 않으며, 불필요한검사 예외를 던지지 않고, 형변환도 필요치 않다.

 

뿐만 아니라 복사 생성자와 복사 팩토리 메서드는 해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있다. 예컨데 관례상 모든 컬렉션 구현체는 Collection이나 Map 타입을 받는 생성자를 제공한다. 이러한 것을 변환 생성자, 변환 팩토리 메서드라고 부른다.

요약

요약하자면 Cloneable 인터페이스를 구현하는 모든 클래스는 접근제어자는 public이고 반환 타입은 자기 자신인 clone 메서드를 재정의 해야한다. 이 메서드는 가장먼저 super.clone() 메서드를 호출한 후 필요한 필드를 적절히 수정한다. 이 말은 그 객체의 내부 깊은 구조에 숨어 있는 모든 가변 객체를 복사한다는 말이다. (깊은 복사) 이러한 깊은 복사는 보통 clone 메서드를 통해 재귀적으로 이루어 지지만 항상 최선의 방법은 아니다.

 

상황별로 clone 메서드의 역할을 수행하기 위해 권장하는 방법은 아래와 같다.

 

Cloneable 인터페이스를 이미 구현한 클래스 확장 : clone이 잘 작동하도록 구현

배열 : clone 메서드 방식이 가장 잘 어울리는 케이스

그 외의 클래스 : 복사 생성자, 복사 팩토리 메서드 사용

 

 

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함