반응형

규칙 13에서 객체 필드 변수는 절대로 public으로 선언하지 말고 getter와 같은 접근자 메서드를 사용하라고 설명했다.

규칙 14에서는 위 내용에 대해 간단하게 설명한다.

 

객체 필드 변수를 public으로 선언하게 되면 외부에서 데이터를 쉽게 조작할 수 있어 캡슐화의 이점을 누릴 수 없다.

 

예를 들어 양수만 표현하는 Point 클래스를 아래와 같이 선언한다면 생성자에서는 음수를 넣을 수 없지만 정상적으로 객체 생성 후 클라이언트가 음수를 직접 세팅할 수 있다.

public class PositivePointWithPublicField {
	public double x;
	public double y;

	public PositivePointWithPublicField(double x, double y) {
		if (x < 0 || y < 0) {
			throw new IllegalArgumentException();
		}

		this.x = x;
		this.y = y;
	}
}

public class Client {
	public static void main(String[] args) {
		PositivePointWithPublicField point = new PositivePointWithPublicField(0, 0);
		point.x = -5;
		point.y = -10;

	}
}

 

하지만 필드를 private로 선언하고 getter를 사용하게 되면 위를 방지할 수 있다.

public class PositivePointWithPrivateField {
	private double x;
	private double y;

	public PositivePointWithPrivateField(double x, double y) {
		if (x < 0 || y < 0) {
			throw new IllegalArgumentException();
		}

		this.x = x;
		this.y = y;
	}

	public double getX() {
		return x;
	}

	public double getY() {
		return y;
	}
}


public class Client {
	public static void main(String[] args) {
		PositivePointWithPrivateField point = new PositivePointWithPrivateField(0, 0);

		// 컴파일 에러
		point.x = -5;
		point.y = -10;

	}
}

 

getter 메서드(또한 setter)는 너무나 많이 사용하기 때문에 IDE에서 자동으로 생성해주는 기능이다. 뿐만 아니라 lombok과 같은 라이브러리에서는 어노테이션을 사용하여 자동으로 생성할 수 있다.

@Getter
public class PositivePointWithPrivateField {
	private double x;
	private double y;

	public PositivePointWithPrivateField(double x, double y) {
		if (x < 0 || y < 0) {
			throw new IllegalArgumentException();
		}

		this.x = x;
		this.y = y;
	}

}

 

다만 Immutable 필드(final 선언)라면 초기에 한 번만 설정할 수 있으므로 public으로 선언하여도 아래와 같이 클라이언트가 조작하지 못하므로 public으로 선언해도 된다. 하지만 굳이 이럴 바에는 getter를 사용하는 게 더 맞다. 또한 public 필드를 두게 되면 후에 호환성을 위해 항상 해당 필드를 유지해야 한다. 하지만 getter를 제공한다면 이미 작성된 클라이언트 코드는 깨트리지 않을 수 있다.

public class PositivePoinWithFinalField {
	public final double x;
	public final double y;

	public PositivePoinWithFinalField(double x, double y) {
		if (x < 0 || y < 0) {
			throw new IllegalArgumentException();
		}

		this.x = x;
		this.y = y;
	}
}

public class Client {
	public static void main(String[] args) {
		PositivePoinWithFinalField point = new PositivePoinWithFinalField(0, 0);

		// final이서 컴파일 에러
		point.x = -5;
		point.y = -10;

	}
}

 

참고

자바에서 제공하는 클래스 중에 규칙 14를 깨트린 클래스들이 존재한다. 따라서 이런 클래스는 코딩할 때 참고하지 않는 것이 좋다.

 

// java.awk.Point
public class Point extends Point2D implements java.io.Serializable {
    public int x;
    public int y;
     
    // ...
}

// java.awk.Dimension
public class Dimension extends Dimension2D implements java.io.Serializable {
    public int width;
    public int height;
    
    // ...
}
반응형
반응형

규칙 13에서는 소프트웨어 설계의 기본적인 원칙 가운데 하나인 캡슐화에 관해서 설명한다.

 

캡슐화는 다른 모듈 간 커플링을 최소화하기 위하여 구현 세부사항을 전부 API 뒤쪽에 숨긴다. 그리고 모듈들은 이 API를 통해서만 서로를 호출한다. 디커플링을 하게 되면 서로 간에 의존성이 낮아지기 때문에 한쪽이 변경되어도 다른 모듈에 영향을 끼칠 걱정이 없다. 또한 다른 개발에서도 유용하게 쓰일 수 있다는 장점이 있다. 자바는 이런 장점을 사용하기 위해 캡슐화를 지원하며 그중 대표적으로 접근 제어(Access Control) 기능을 제공한다.

 

원칙은 단순하다. 가능한 한 다른 곳에서 접근 불가능하도록 만들어라. 클라이언트에게 제공하는 API만 public 또는 protected로 하라.

 

 

1. 클래스와 인터페이스에 public을 붙이지 않을 수 있는지 확인해보자.

public으로 한번 릴리스한 클래스와 인터페이스는 호환성을 보장하기 위해 계속 제공해야 한다. (대부분 지키지 않지만)

 

예를 들어 자바에서는 Enum에 특화된 Set을 제공하는 EnumSet을 제공하는데, EnumSet을 public으로 제공하여 누구나 사용할 수 있다. 하지만 실제로 정적 팩토리 메서드가 제공하는 객체는 RegularEnumSet, JumboEnumSet이 있는데 이 둘은 직접적으로 사용할 수 없도록 package private 접근 제어자를 사용한다. 즉 jdk 개발자는 RegularEnumSet 클래스를 변경하던, 삭제하던 호환성에 대해 걱정할 필요 없다. 

// EnumSet은 public으로 추상 클래스 제공
public abstract class EnumSet<E extends Enum<E>> extends AbstractSet<E>
    implements Cloneable, java.io.Serializable
{
	...
    public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
        Enum<?>[] universe = getUniverse(elementType);
        if (universe == null)
            throw new ClassCastException(elementType + " not an enum");

        if (universe.length <= 64)
            return new RegularEnumSet<>(elementType, universe);
        else
            return new JumboEnumSet<>(elementType, universe);
    }
	...
}

// package private
class RegularEnumSet<E extends Enum<E>> extends EnumSet<E> {
	...
}

 

2. 클래스의 멤버 변수들은 최대한 package-private까지만 지원하자. protected도 광범위하다.

객체 필드변수는 절대로 public으로 선언하면 안 된다. 필드를 public으로 선언하면 필드에 저장될 값을 제한할 수 없게 되어 그 필드에 관계된 불변식을 강제할 수 없다. 그러므로 getter와같은 접근자 메서드를 사용하는 게 좋다.

 

단, 상수는 public static final로 선언될 수 있다. 아래는 BigDeimal의 상수 예시이다.

    // Cache of common small BigDecimal values.
    private static final BigDecimal ZERO_THROUGH_TEN[] = {
        new BigDecimal(BigInteger.ZERO,       0,  0, 1),
        new BigDecimal(BigInteger.ONE,        1,  0, 1),
        new BigDecimal(BigInteger.TWO,        2,  0, 1),
        new BigDecimal(BigInteger.valueOf(3), 3,  0, 1),
        new BigDecimal(BigInteger.valueOf(4), 4,  0, 1),
        new BigDecimal(BigInteger.valueOf(5), 5,  0, 1),
        new BigDecimal(BigInteger.valueOf(6), 6,  0, 1),
        new BigDecimal(BigInteger.valueOf(7), 7,  0, 1),
        new BigDecimal(BigInteger.valueOf(8), 8,  0, 1),
        new BigDecimal(BigInteger.valueOf(9), 9,  0, 1),
        new BigDecimal(BigInteger.TEN,        10, 0, 2),
    };

	public static final BigDecimal ZERO = zeroThroughTen[0];
	public static final BigDecimal ONE = zeroThroughTen[1];
	public static final BigDecimal TEN = zeroThroughTen[10];

 

하지만 상수라도 기본 자료형 값들을 갖거나, 변경 불가능 객체를 참조하게 해야 한다.

public static final Thing[] VALUES = { ... };

위 코드와 같이 길이가 0이 아닌 배열 상수 필드는 배열 레퍼런스는 변경하지 못하지만 배열 레퍼런스 안에 존재하는 값은 변경할 수 있다. 따라서 public static final 배열 필드를 두거나, 배열 필드를 반환하는 접근자를 정의하면 안 된다.

 

따라서 클라이언트에게는 배열 필드의 상수 값을 복사해서 넘겨주는 방식으로 이 문제를 해결한다.

방법 1.

	private static final String[] UNMODIFY_NUM_LIST = { "1", "2", "3", "4" };
	public static final List<String> NUM_LIST_2 = Collections.unmodifiableList(Arrays.asList(UNMODIFY_NUM_LIST));

방법 2.

	private static final String[] CLONE_NUM_LISE = { "1", "2", "3", "4" };
	public static final String[] NUM_LIST_3() {
		return CLONE_NUM_LISE.clone();
	}

 

3. 클래스의 메서드도 최대한 접근 권한을 줄이자.

클래스의 메서드도 최대한 접근 권한을 줄여야 한다. 하지만 접근 권한을 줄일 수 없는 경우가 있다. 상위 클래스 메서드를 오버라이딩할 때는 원래 메서드의 접근 권한보다 낮은 권한을 설정할 수 없다.

 

 

결론은 접근 권한은 가능한 낮춰라.

 

반응형
반응형

최상위 객체인 Object에 선언되어 있는 메서드는 아니지만 자주 사용되는 compareTo 메서드에 대해 설명한다.

 

int java.lang.String.compareTo(String anotherString)

자바에서 기본적으로 제공하는 값 클래스(value class)는 compareTo 메서드가 구현되어 있으며 또한 String 클래스에도 compareTo가 구현되어 있다. String.compareTo 메서드는 문자열을 비교하여 같으면 0을, 사전순으로 작으면 음수를, 사전순으로 크면 양수를 리턴한다.

	public static void main(String[] args) {
		String str1 = "apple";

		System.out.println(str1.compareTo("banana")); // -1
		System.out.println(str1.compareTo("apple")); // 0
		System.out.println(str1.compareTo("abc")); // 14
	}

 

String.compareTo 메서드의 소스코드는 아래와 같다.

    public int compareTo(String anotherString) {
        int len1 = value.length;
        int len2 = anotherString.value.length;
        int lim = Math.min(len1, len2);
        char v1[] = value;
        char v2[] = anotherString.value;

        int k = 0;
        while (k < lim) {
            char c1 = v1[k];
            char c2 = v2[k];
            if (c1 != c2) {
                return c1 - c2;
            }
            k++;
        }
        return len1 - len2;
    }

1. 문자를 하나씩 비교하여 다를 경우 차를 구해 바로 리턴한다.

2. 길이가 작은 문자열의 길이 만큼 비교하여도 같다면 문자열의 길이가 작은게 사전순으로 더 앞에 있다고 판단하고 차를 구해 리턴한다.

 

이렇게 compareTo 메서드는 해당 클래스 객체들의 자연적 순서를 비교할 수 있도록 기능을 제공한다. 그리고 다들 알겠지만 compareTo는 일반적인 메서드가 아닌 Comparable 인터페이스에 정의되어 있는 유일한 메서드이다.

 

Comparable 인터페이스

Comparable 인터페이스는 클래스 객체에 순서를 정의하도록 한다. 이 순서를 클래스의 자연 순서라고 하며 클래스의 compareTo 메소드는 자연 순서를 계산할 수 있도록 기능을 구현해야 한다. 그래서 위와 같이 String 클래스는 Compareable 인터페이스를 구현하였고, 자연 순서를 계산할 수 있도록 compareTo 메서드를 구현한 것이다.

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
	...
}

 

규칙9에서 설명했던 hashCode는 규약을 따르지 않으면 hashCode를 사용하는 자바 API(ex : HashMap) 동작에 문제가 발생한다고 설명했다. 마찬가지로 Comparable의 compareTo 메서드를 사용하는 자바 API도 여러 개 존재하는데 compareTo 규약을 준수하지 않을 경우 API를 오동작시킬 수 있다. 아래는 compareTo를 사용하는 API의 예시이다.

 

- void java.util.Arrays.sort(Object[] a)

	public static void arraySort() {
		String arr[] = { "banana", "apple", "google", "aws" };

		Arrays.sort(arr);
		System.out.printf("%s\n", Arrays.toString(arr)); // [apple, aws, banana, google]

		Arrays.sort(arr, Collections.reverseOrder());

		System.out.printf("%s\n", Arrays.toString(arr)); // [google, banana, aws, apple]
	}

 

- java.util.TreeSet<E>

	public static void treeSet() {
		String arr[] = { "banana", "apple", "google", "aws" };

		Set<String> s = new TreeSet<String>();
		s.addAll(Arrays.asList(arr));

		System.out.println(s); // [apple, aws, banana, google]
	}

 

그렇다면 이제 compareTo를 잘 구현해야한다. 규약을 요약하자면 아래와 같다.

 

- Symmetry(대칭성)

X가 Y보다 크다면 Y는 X보다 작아야 한다. 반대로 X가 Y보다 작다면 Y는 X보다 커야 한다. 또한 X가 Y랑 같다면 Y도 X랑 같아야 한다.

 

- Transitivity(추이성)

X가 Y보다 크고, Y가 Z보다 크다면 X는 Z보다 커야한다.

 

- Consistent(일관성)

X와 Y가 같다면 X와 Z와의 비교 결과와 Y와 Z의 비교 결과는 항상 같다.

 

(이펙티브 자바 책에서는 위 순서를 반사성, 대칭성, 추이성이라고 표현하지만, 필자는 잘못되었다고 생각한다.)

 

- (강력히 추천하지만 절대적으로 요구되는 것은 아닌) X와 Y의 compareTo의 결과가 0이라면 equlas의 결과는 true여야 한다.

네번째 규약은 특별한 경우가 아니라면 지키는게 좋다. 자바에서 제공하는 대부분의 컬렉션 구현체들은 마지막 규약이 지켜졌다고 생각하고 동작한다. 만약 규약을 지키지 않으면 엄청난 재앙을 가져오진 않지만, 그래도 약간의 문제가 발생할 수 있다.

 

네번째 규약을 지키지 않은 대표적인 클래스가 있는데 예시는 아래와 같다.

	public static void main(String[] args) {
		BigDecimal a = new BigDecimal("1.0");
		BigDecimal b = new BigDecimal("1.00");

		System.out.println(a.equals(b)); // false
		System.out.println(a.compareTo(b)); // 0
		System.out.println((a.compareTo(b) == 0) == a.equals(b)); // false

		Set<BigDecimal> hashSet = new HashSet<BigDecimal>();
		hashSet.add(a);
		hashSet.add(b);

		System.out.println(hashSet.size()); // 2

		Set<BigDecimal> treeSet = new TreeSet<BigDecimal>();
		treeSet.add(a);
		treeSet.add(b);

		System.out.println(treeSet.size()); // 1
	}

BigDecimal은 소수점까지 모두 동일해야지 동일한 것으로 판단한다. 하지만 compare는 크기를 비교하므로 0을 리턴한다. 따라서 네번 째 규약을 지키지 않는 클래스이다. 이렇기 때문에 equals(hashCode)로 동일성을 판단하는 HashSet에서는 두 원소를 넣어도 다르다고 판단하여 두 개의 객체가 존재한다. 하지만 TreeSet은 compareTo로 비교하기 때문에 두 객체는 동일하다고 판단하여 하나의 객체만 존재하게 된다.

 

근데 만약 이미 구현되어 있는 클래스의 compareTo 순서 비교 방식이 아닌 새로운 순서 비교 방식을 사용하고 싶다면 어떻게 해야할까? 이럴 경우 Comparator 인터페이스를 구현하여 사용하면 된다.

 

Comparator 인터페이스

일반적으로 Comparable.compareTo를 사용하는 메서드는 Comparator의 compare 메서드를 사용할 수 있도록 구현되어있다. (ex : Collections.sort 또는 Arrays.sort) 따라서 미리 구현되어 있는 compareTo 방식이 아닌 새로운 방식으로 순서를 지정하고 싶다면 Comparator를 구현하면 된다.

 

아래는 예시이다.

	public static void main(String[] args) {
		String arr[] = { "banana", "apple", "google", "aws" };

		Arrays.sort(arr, new Comparator<String>() {
			public int compare(String o1, String o2) {
				return o2.compareTo(o1); // o1을 o2와 비교한 것이 아닌 o2를 o1과 비교
			}
		});

		System.out.printf("%s\n", Arrays.toString(arr)); // [google, banana, aws, apple]

	}

 

Arrays.sort에 새롭게 구현한 Comparator 구현체를 사용하였다. 자세히 보면 기존 o1.compareTo(o2)가 아닌 o2.compareTo(o1)을 사용하여 사전순이 아닌 역사전순으로 비교하도록 하였다.

 

이렇게 자신이 원하는 순서를 다시 재정의할 수 있는데, 자바에서는 자주 사용하는 비교 방식의 Comparator를 미리 제공한다. 위에 예시였던 arraySort() 메서드에서 보면 Collections.reverseOrder()를 사용하였는데, 이는 우리가 지금 구현한 Comparator와 동일하게 구현되어 있는 구현체이다. 이 외에도 String 클래스에서는 대소문자를 무시하여 비교하는 구현체를 제공한다. 아래는 해당 소스이다.

    public static final Comparator<String> CASE_INSENSITIVE_ORDER
                                         = new CaseInsensitiveComparator();
    private static class CaseInsensitiveComparator
            implements Comparator<String>, java.io.Serializable {
        // use serialVersionUID from JDK 1.2.2 for interoperability
        private static final long serialVersionUID = 8575799808933029326L;

        public int compare(String s1, String s2) {
            int n1 = s1.length();
            int n2 = s2.length();
            int min = Math.min(n1, n2);
            for (int i = 0; i < min; i++) {
                char c1 = s1.charAt(i);
                char c2 = s2.charAt(i);
                if (c1 != c2) {
                    c1 = Character.toUpperCase(c1);
                    c2 = Character.toUpperCase(c2);
                    if (c1 != c2) {
                        c1 = Character.toLowerCase(c1);
                        c2 = Character.toLowerCase(c2);
                        if (c1 != c2) {
                            // No overflow because of numeric promotion
                            return c1 - c2;
                        }
                    }
                }
            }
            return n1 - n2;
        }

        /** Replaces the de-serialized object. */
        private Object readResolve() { return CASE_INSENSITIVE_ORDER; }
    }

 

반응형
반응형

자바에서 최상위 객체인 Object는 하위 객체들이 오버라이딩하여 사용하도록 설계된 메서드들이 있다. (equals, hashCode, toString, clone, finalize) 그리고 이 메서드들은 일반 규약이 존재하는데 이를 따르지 않으면 자바에서 제공하는 클래스와 함께 사용할 때 제대로 동작하지 않는다.

 

이번 장에서는 clone 메서드에 대해 설명한다.

 

Cloneable 인터페이스

clone 메서드는 Object 클래스에 정의되어 있고, 하위 객체가 오버라이딩하여 재정의하여 사용한다. 하지만 clone 메서드를 정상적으로 사용하기 위해서는 추가적으로 해당 클래스가 Cloneable 인터페이스를 implements 해야한다.

 

사실 이런 구현방식은 정말 기괴한 방식이다. Cloneable 인터페이스에 clone 메서드가 정의만 되어 있었어도 Cloneable 인터페이스를 구현하는 클래스는 강제적으로 clone 메서드를 구현해야 하기때문에 아주 상식적인 방식으로 clone의 동작 방식을 이해할 수 있었을 것이다.

 

하지만 자바는 Cloneable을 Marker Interface로 구현하였고, natvie 메서드인 Object.clone()에서 instanceof를 사용하여 Cloneable을 implement한 클래스인 경우 clone 메서드를 호출하고, Cloneable를 implement하지 않은 클래스인 경우 CloneNotSupportedException 예외를 던지도록 구현되어 있다.

 

아래는 Cloneable의 소스이다.

package java.lang;

/**
 * A class implements the <code>Cloneable</code> interface to
 * indicate to the {@link java.lang.Object#clone()} method that it
 * is legal for that method to make a
 * field-for-field copy of instances of that class.
 * <p>
 * Invoking Object's clone method on an instance that does not implement the
 * <code>Cloneable</code> interface results in the exception
 * <code>CloneNotSupportedException</code> being thrown.
 * <p>
 * By convention, classes that implement this interface should override
 * <tt>Object.clone</tt> (which is protected) with a public method.
 * See {@link java.lang.Object#clone()} for details on overriding this
 * method.
 * <p>
 * Note that this interface does <i>not</i> contain the <tt>clone</tt> method.
 * Therefore, it is not possible to clone an object merely by virtue of the
 * fact that it implements this interface.  Even if the clone method is invoked
 * reflectively, there is no guarantee that it will succeed.
 *
 * @author  unascribed
 * @see     java.lang.CloneNotSupportedException
 * @see     java.lang.Object#clone()
 * @since   JDK1.0
 */
public interface Cloneable {
}

 

Object.clone() 메서드

위에서 언급한 것처럼 Object.clone의 동작방식은 기괴하지만 널리 사용되고 있으므로 알아둬야 한다. 먼저 Object.clone() 메서드는 아래와 같다.

    /**
     * Creates and returns a copy of this object.  The precise meaning
     * of "copy" may depend on the class of the object. The general
     * intent is that, for any object {@code x}, the expression:
     * <blockquote>
     * <pre>
     * x.clone() != x</pre></blockquote>
     * will be true, and that the expression:
     * <blockquote>
     * <pre>
     * x.clone().getClass() == x.getClass()</pre></blockquote>
     * will be {@code true}, but these are not absolute requirements.
     * While it is typically the case that:
     * <blockquote>
     * <pre>
     * x.clone().equals(x)</pre></blockquote>
     * will be {@code true}, this is not an absolute requirement.
     * <p>
     * By convention, the returned object should be obtained by calling
     * {@code super.clone}.  If a class and all of its superclasses (except
     * {@code Object}) obey this convention, it will be the case that
     * {@code x.clone().getClass() == x.getClass()}.
     * <p>
     * By convention, the object returned by this method should be independent
     * of this object (which is being cloned).  To achieve this independence,
     * it may be necessary to modify one or more fields of the object returned
     * by {@code super.clone} before returning it.  Typically, this means
     * copying any mutable objects that comprise the internal "deep structure"
     * of the object being cloned and replacing the references to these
     * objects with references to the copies.  If a class contains only
     * primitive fields or references to immutable objects, then it is usually
     * the case that no fields in the object returned by {@code super.clone}
     * need to be modified.
     * <p>
     * The method {@code clone} for class {@code Object} performs a
     * specific cloning operation. First, if the class of this object does
     * not implement the interface {@code Cloneable}, then a
     * {@code CloneNotSupportedException} is thrown. Note that all arrays
     * are considered to implement the interface {@code Cloneable} and that
     * the return type of the {@code clone} method of an array type {@code T[]}
     * is {@code T[]} where T is any reference or primitive type.
     * Otherwise, this method creates a new instance of the class of this
     * object and initializes all its fields with exactly the contents of
     * the corresponding fields of this object, as if by assignment; the
     * contents of the fields are not themselves cloned. Thus, this method
     * performs a "shallow copy" of this object, not a "deep copy" operation.
     * <p>
     * The class {@code Object} does not itself implement the interface
     * {@code Cloneable}, so calling the {@code clone} method on an object
     * whose class is {@code Object} will result in throwing an
     * exception at run time.
     *
     * @return     a clone of this instance.
     * @throws  CloneNotSupportedException  if the object's class does not
     *               support the {@code Cloneable} interface. Subclasses
     *               that override the {@code clone} method can also
     *               throw this exception to indicate that an instance cannot
     *               be cloned.
     * @see java.lang.Cloneable
     */
    protected native Object clone() throws CloneNotSupportedException;

 

clone 메서드는 native 메서드로 작성되어 있으며 Cloneable 인터페이스를 구현하지 않은 클래스가 해당 메서드를 사용할 경우 CloneNotSupportedException 예외를 던지도록 정의되어 있다.

 

clone 메서드도 마찬가지로 규약이 존재하지만 다른 메서드의 비해 규약이 느슨한다.

 

1. x.clone() != x will be true, and that the expression.

객체의 복사본을 만들어서 반환하는 것이기 때문에 원본과 복사본은 동일할 수는 없다.

 

2. x.clone().getClass() == x.getClass() will be true, but these are not absolute requirements.

원본과 복사본의 클래스 타입은 동일해야하지만, 반드시 그래야 하는 것은 아니다. 하지만 이 규약은 너무 느슨하다. clone을 사용하는 입장에서는 원본과 복사본의 타입이 동일 할 것이라고 생각한다

 

3.  x.clone().equals(x) will be true, this is not an absolute requirement.

 

By convention, the returned object should be obtained by calling super.clone. If a class and all of its superclasses (except Object) obey this convention, it will be the case that x.clone().getClass() == x.getClass().

 

 

--- 추후 추가 작성 

 

반응형
반응형

자바에서 최상위 객체인 Object는 하위 객체들이 오버라이딩하여 사용하도록 설계된 메서드들이 있다. (equals, hashCode, toString, clone, finalize) 그리고 이 메서드들은 일반 규약이 존재하는데 이를 따르지 않으면 자바에서 제공하는 클래스와 함께 사용할 때 제대로 동작하지 않는다.

 

이번 장에서는 toString 메서드에 대해 설명한다.

 

Object.toString() 메서드

Object.toString() 메서드는 print와 같은 함수, assert, debugger 등에 객체가 전달되면 자동으로 호출되는 메서드로 해당 객체의 대한 정보를 사람이 읽기 쉽도록 간략하지만 유용한 정보를 제공하도록 하는 메서드이다.

 

하지만 Object 클래스에 정의된 toString() 메서드는 클래스 이름 다음에 @ 기호와 16진수로 표현된 해시 코드가 붙은 문자열을 출력하도록 아래와 같이 구현되어 있다.

public class Object {
	...

    public String toString() {
        return getClass().getName() + "@" + Integer.toHexString(hashCode());
    }

 

따라서 toString() 명세서에는 다음과 같이 작성되어 있다.

Returns a string representation of the object. In general, the toString method returns a string that "textually represents" this object. The result should be a concise but informative representation that is easy for a person to read. It is recommended that all subclasses override this method.

 

toString 메서드 구현 방법

1. 가능하다면 객체 내의 중요 정보를 전부 담자.

 

2. 중요 정보가 너무 많다면 최대한 요약해 표현하자.

 

3. 표현되는 정보들은 getter와 같은 메서드를 제공하여 정보를 가져갈 수 있도록 하자.

 

4. 누군가는 toString이 리턴하는 문자열을 파싱하여 사용할 수 있다. 따라서 항상 명세서에 toString이 리턴하는 문자열에 대해 자세히 표현한다.

 

아래는 9장에서 사용한 클래스의 toString의 예제이다.

public class PhoneNumberWithHashCode {
	private final int areaCode;
	private final int prefix;
	private final int lineNumber;

	public PhoneNumberWithHashCode(int areaCode, int prefix, int lineNumber) {
		this.areaCode = areaCode;
		this.prefix = prefix;
		this.lineNumber = lineNumber;
	}

	/**
	 *
	 * 전화번호를 문자열로 변환해서 반환한다.
	 * 문자열은 "(XXX) YYY-ZZZZ" 형식으로 표현하여, 지역번호(areaCode), 국번(prefix), 회선번호(lineNumber) 순이다.
	 * 형식은 변경될 수 있다.
	 */
	@Override
	public String toString() {
		return String.format("(%03d) %03d-%04d", areaCode, prefix, lineNumber);
	}

	public static void main(String[] args) {
		PhoneNumberWithHashCode p1 = new PhoneNumberWithHashCode(111, 654, 7009);

		System.out.println(p1);
	}

}

 

아래는 자주 사용하는 API들에 대한 toString 소스이다.

 

- java.util.AbstractMap : 대부분의 *Map들이 상속하는 클래스

Map<String, String> map = new HashMap<>();
map.put("key1", "value1");
map.put("key2", "value2");

System.out.println(map.toString()); // {key1=value1, key2=value2}
    /**
     * Returns a string representation of this map.  The string representation
     * consists of a list of key-value mappings in the order returned by the
     * map's <tt>entrySet</tt> view's iterator, enclosed in braces
     * (<tt>"{}"</tt>).  Adjacent mappings are separated by the characters
     * <tt>", "</tt> (comma and space).  Each key-value mapping is rendered as
     * the key followed by an equals sign (<tt>"="</tt>) followed by the
     * associated value.  Keys and values are converted to strings as by
     * {@link String#valueOf(Object)}.
     *
     * @return a string representation of this map
     */
    public String toString() {
        Iterator<Entry<K,V>> i = entrySet().iterator();
        if (! i.hasNext())
            return "{}";

        StringBuilder sb = new StringBuilder();
        sb.append('{');
        for (;;) {
            Entry<K,V> e = i.next();
            K key = e.getKey();
            V value = e.getValue();
            sb.append(key   == this ? "(this Map)" : key);
            sb.append('=');
            sb.append(value == this ? "(this Map)" : value);
            if (! i.hasNext())
                return sb.append('}').toString();
            sb.append(',').append(' ');
        }
    }

 

- java.util.Date : 날짜 및 시각 정보를 관리하는 클래스

System.out.println(new Date().toString()); // Mon Dec 13 23:46:32 KST 2021
    /**
     * Converts this <code>Date</code> object to a <code>String</code>
     * of the form:
     * <blockquote><pre>
     * dow mon dd hh:mm:ss zzz yyyy</pre></blockquote>
     * where:<ul>
     * <li><tt>dow</tt> is the day of the week (<tt>Sun, Mon, Tue, Wed,
     *     Thu, Fri, Sat</tt>).
     * <li><tt>mon</tt> is the month (<tt>Jan, Feb, Mar, Apr, May, Jun,
     *     Jul, Aug, Sep, Oct, Nov, Dec</tt>).
     * <li><tt>dd</tt> is the day of the month (<tt>01</tt> through
     *     <tt>31</tt>), as two decimal digits.
     * <li><tt>hh</tt> is the hour of the day (<tt>00</tt> through
     *     <tt>23</tt>), as two decimal digits.
     * <li><tt>mm</tt> is the minute within the hour (<tt>00</tt> through
     *     <tt>59</tt>), as two decimal digits.
     * <li><tt>ss</tt> is the second within the minute (<tt>00</tt> through
     *     <tt>61</tt>, as two decimal digits.
     * <li><tt>zzz</tt> is the time zone (and may reflect daylight saving
     *     time). Standard time zone abbreviations include those
     *     recognized by the method <tt>parse</tt>. If time zone
     *     information is not available, then <tt>zzz</tt> is empty -
     *     that is, it consists of no characters at all.
     * <li><tt>yyyy</tt> is the year, as four decimal digits.
     * </ul>
     *
     * @return  a string representation of this date.
     * @see     java.util.Date#toLocaleString()
     * @see     java.util.Date#toGMTString()
     */
    public String toString() {
        // "EEE MMM dd HH:mm:ss zzz yyyy";
        BaseCalendar.Date date = normalize();
        StringBuilder sb = new StringBuilder(28);
        int index = date.getDayOfWeek();
        if (index == BaseCalendar.SUNDAY) {
            index = 8;
        }
        convertToAbbr(sb, wtb[index]).append(' ');                        // EEE
        convertToAbbr(sb, wtb[date.getMonth() - 1 + 2 + 7]).append(' ');  // MMM
        CalendarUtils.sprintf0d(sb, date.getDayOfMonth(), 2).append(' '); // dd

        CalendarUtils.sprintf0d(sb, date.getHours(), 2).append(':');   // HH
        CalendarUtils.sprintf0d(sb, date.getMinutes(), 2).append(':'); // mm
        CalendarUtils.sprintf0d(sb, date.getSeconds(), 2).append(' '); // ss
        TimeZone zi = date.getZone();
        if (zi != null) {
            sb.append(zi.getDisplayName(date.isDaylightTime(), TimeZone.SHORT, Locale.US)); // zzz
        } else {
            sb.append("GMT");
        }
        sb.append(' ').append(date.getYear());  // yyyy
        return sb.toString();
    }
반응형
반응형

자바에서 최상위 객체인 Object는 하위 객체들이 오버라이딩하여 사용하도록 설계된 메서드들이 있다. (equals, hashCode, toString, clone, finalize) 그리고 이 메서드들은 일반 규약이 존재하는데 이를 따르지 않으면 자바에서 제공하는 클래스와 함께 사용할 때 제대로 동작하지 않는다.

 

이번 장에서는 hashCode 메서드에 대해 설명한다.

 

Object.hashCode() 메서드

해시 코드란 객체를 식별하는 하나의 정수 값을 말한다. Object.hashCode() 메서드는 해당 객체의 해시 코드 값을 반환한다. 해시 코드는 java.util.HashMap과 같은 해시(hash) 기반 컬렉션에서 사용된다.

 

Object.hashCode() 메서드는 아래와 같다.

public class Object {
	 ...
	
	public native int hashCode();
}

 

Object의 hashCode는 native함수로 C언어로 작성되어 있다. 좀 더 자세히 알고 싶다면 다음을 참고한다. link to hashCode.

 

Object.hashCode() 메서드를 오버라이딩하여 재정의할 때 준수해야 하는 일반 규약이 Object 클래스 명세서에 작성되어 있다.

  • Whenever it is invoked on the same object more than once during an execution of a Java application, the hashCode method must consistently return the same integer, provided no information used in equals comparisons on the object is modified. This integer need not remain consistent from one execution of an application to another execution of the same application. 
  • If two objects are equal according to the equals(Object) method, then calling the hashCode method on each of the two objects must produce the same integer result.
  • It is not required that if two objects are unequal according to the java.lang.Object.equals(java.lang.Object) method, then calling the hashCode method on each of the two objects must produce distinct integer results. However, the programmer should be aware that producing distinct integer results for unequal objects may improve the performance of hash tables.

 

해시 코드는 반드시 구현해야 하는 것은 아니다. 하지만 두 번째 규약에 의하면 Object.equals() 메서드를 재정의 했다면 hashCode도 반드시 재정의해야 한다.

 

만약 두 번째 규약을 지키지 않으면 어떻게 되는지 보자.

 

두 번째 규약을 지키지 않았을 경우

아래는 equals() 메서드를 규약에 맞춰 작성한 PhoneNumer 클래스의 코드이다.

public class PhoneNumber {
	private final int areaCode;
	private final int prefix;
	private final int lineNumber;

	public PhoneNumber(int areaCode, int prefix, int lineNumber) {
		this.areaCode = areaCode;
		this.prefix = prefix;
		this.lineNumber = lineNumber;
	}

	@Override
	public boolean equals(Object obj) {
		if (obj == this) {
			return true;
		}
		if (!(obj instanceof PhoneNumber)) {
			return false;
		}

		PhoneNumber phoneNumber = (PhoneNumber) obj;

		// Since lineNumber may be the most different, check first.
		return phoneNumber.lineNumber == lineNumber && phoneNumber.prefix == prefix
		        && phoneNumber.areaCode == phoneNumber.areaCode;
	}

	public static void main(String[] args) {
		Map<PhoneNumber, String> map = new HashMap<PhoneNumber, String>();

		PhoneNumber p1 = new PhoneNumber(1, 2, 3);
		PhoneNumber p2 = new PhoneNumber(1, 2, 3);

		System.out.println(p1.equals(p2)); // true

		map.put(p1, "Phone");

		System.out.println(map.get(p1)); // Phone
		System.out.println(map.get(p2)); // null

		System.out.println(p1.hashCode()); // 366712642
		System.out.println(p2.hashCode()); // 1829164700
	}

}

p1과 p2는 논리적으로 동일하다. 즉, 새롭게 정의한 equals 메서드에서 두 객체는 동일하다고 판단한다. 그다음 HashMap에 p1을 키로 하여 데이터를 삽입하였다. 그 후, HashMap에서 데이터를 꺼낼 때 p1과 p2를 실험해본 결과 p1은 정상적으로 출력되지만 p2는 null을 반환한다.

 

Map은 동일한 키를 사용한다면 동일한 값을 반환해야 한다. 따라서 우리는 map.get(p2)도 Phone이 출력되어야 한다고 생각한다. 왜냐하면 p1과 p2는 논리적으로 동일하다고 판단하기 때문이다. 하지만 HashMap은 동일하다는 기준을 hashCode 값을 사용하여 판단한다. 따라서 HashMap에 동일성의 기준과 사람의 동일성의 기준을 같게 하기 위해서 equals 메서드를 재정의하였으면 hashCode 메서드도 재정의해야 한다.

 

hashCode()를 재정의 하기만 하면 된다?

이제 PhoneNumber의 문제를 발견했으니 hashCode() 메서드를 아래와 같이 재정의해보자. 

public class PhoneNumberOnlyAreaCode {
	private final int areaCode;
	private final int prefix;
	private final int lineNumber;

	...

	@Override
	public int hashCode() {
		return areaCode;
	}
    
	public static void main(String[] args) {
		Map<PhoneNumberOnlyAreaCode, String> map = new HashMap<PhoneNumberOnlyAreaCode, String>();

		PhoneNumberOnlyAreaCode p1 = new PhoneNumberOnlyAreaCode(1, 2, 3);
		PhoneNumberOnlyAreaCode p2 = new PhoneNumberOnlyAreaCode(1, 2, 3);

		System.out.println(p1.equals(p2)); // true

		map.put(p1, "Phone");

		System.out.println(map.get(p1)); // Phone
		System.out.println(map.get(p2)); // Phone

		System.out.println(p1.hashCode()); // 1
		System.out.println(p2.hashCode()); // 1
	}

}

 

위 결과를 보면 원하는 결과가 나왔다. 해시 코드를 areaCode를 리턴하도록 하였다.

 

그러나 위 코드는 areaCode가 같은 모든 객체는 같은 해시 코드를 가지게 되는데, 이는 해시 기반의 API를 사용할 때 끔찍한 결과를 가져올 것이다. areaCode가 같은 객체는 전부 같은 버킷에 해시되므로, 해시 테이블은 아주 긴 링크드 리스트가 많이 생기게 될 것이므로 원하는 성능이 나타나지 않는다.

 

따라서 hashCode의 세 번째 규약처럼 해시 코드가 꼭 다를 필요는 없지만, 해시 코드가 값이 다를수록 해시 테이블의 성능이 향상될 수 있다고 언급한 것이다.

 

hashCode 메서드 구현 순서

세 번째 규약에서 동일하지 않는 객체들끼리는 hashCode가 꼭 다를 필요는 없지만 다르면 성능적으로 좋다고 하였다. 서로 다른 객체들을 모든 가능한 해시 값에 균등하게 배분해야 하는데 수학자들이 그러한 이상적인 hashCode 메서드를 만드는 방법을 정의하였다.

 

  1. Create a int result and assign a non-zero value.
  2. For every field f tested in the equals() method, calculate a hash code c by:
    • If the field f is a boolean: calculate (f ? 0 : 1);
    • If the field f is a byte, char, short or int: calculate (int)f;
    • If the field f is a long: calculate (int)(f ^ (f »> 32));
    • If the field f is a float: calculate Float.floatToIntBits(f);
    • If the field f is a double: calculate Double.doubleToLongBits(f) and handle the return value like every long value;
    • If the field f is an object: Use the result of the hashCode() method or 0 if f == null;
    • If the field f is an array: see every field as separate element and calculate the hash value in a recursive fashion and combine the values as described next.
  3. Combine the hash value c with result:
    • result = 37 * result + c
  4. Return result

 

위 PhoneNumber에 구현 예제는 다음과 같다.

public class PhoneNumberWithHashCode {
	...

	@Override
	public int hashCode() {
		int result = 17;

		result = 31 * result + areaCode;
		result = 31 * result + prefix;
		result = 31 * result + lineNumber;

		return result;
	}

}

 

PhoneNumber는 필드가 세 개뿐이므로 해시 코드 값을 계산하는데 비용이 크지 않다. 하지만 해시 코드 계산 비용이 높은 클래스를 만들 때는 필요할 때마다 해시 코드를 재계산하는 대신 객체 안에 캐시 해 두어야 할 수도 있다. 우리가 자주 사용하는 String 클래스의 코드를 보자.

 public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0

	...
 	
	public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

 

String 클래스의 hashCode는 글자의 길이만큼 반복이 발생하면서 hashCode 값을 계산한다. 아주 긴 문자열이라면 해시 코드를 계산하는데 비용이 크므로 String 클래스는 해시 코드를 계산한 후 캐싱해서 사용한다.

 

다만, 이렇게 캐시를 사용할 경우에는 변경 불가능 클래스여야 한다. 왜냐하면 중요 필드가 변경될 경우, 해시값도 달라져야 하는데 캐시를 해두고 위 로직처럼 한다면 동일한 해시값을 계속 반환하기 때문이다.

 

해시 코드를 구현할 때 주의할 점은 객체의 중요한 변수를 일부 빼고 해시 코드를 계산하면 문제가 발생할 수 있다. JDK 1.2 이전의 String의 hashCode는 문자열의 첫 번째 문자부터 일정 간격으로 열두 개 문자를 추출해서 해시 값을 계산했다. 추출한 12개의 문자들이 같은 경우가 많을 경우에는 해시 테이블이 끔찍한 성능을 보였다.

 

equals, hashCode를 자동으로 생성해주는 라이브러리

규칙 2에서 builder 패턴을 쉽게 생성해주는 lombok에 대해서 간단하게 설명했다. 마찬가지로 equals와 hashCode는 구현은 쉽지만 작성하기 귀찮으며 코드의 양이 많아져 보기가 싫다.

 

따라서 lombok에서는 equals와 hashCode에 대해서도 어노테이션을 제공한다.

@EqualsAndHashCode
public class EqualsAndHashCodeExample {
	private transient int transientVar = 10;
	private String name;
	private double score;
	@EqualsAndHashCode.Exclude
	private Shape shape = new Square(5, 10);
	private String[] tags;
	@EqualsAndHashCode.Exclude
	private int id;
}

 

@EqualsAndHashCode.Exclude를 사용하여 equals와 hashCode에서 배제할 필드를 선택할 수 있다.

 

아래는 최종적으로 생성되는 코드이다.

public class EqualsAndHashCodeExample {
	private transient int transientVar = 10;
	private String name;
	private double score;
	private Shape shape = new Square(5, 10);
	private String[] tags;
	private int id;

	public String getName() {
		return this.name;
	}

	@Override
	public boolean equals(Object o) {
		if (o == this) {
			return true;
		}
		if (!(o instanceof EqualsAndHashCodeExample)) {
			return false;
		}
		EqualsAndHashCodeExample other = (EqualsAndHashCodeExample) o;
		if (!other.canEqual(this)) {
			return false;
		}
		if (this.getName() == null ? other.getName() != null : !this.getName().equals(other.getName())) {
			return false;
		}
		if (Double.compare(this.score, other.score) != 0) {
			return false;
		}
		if (!Arrays.deepEquals(this.tags, other.tags)) {
			return false;
		}
		return true;
	}

	@Override
	public int hashCode() {
		final int PRIME = 59;
		int result = 1;
		final long temp1 = Double.doubleToLongBits(this.score);
		result = (result * PRIME) + (this.name == null ? 43 : this.name.hashCode());
		result = (result * PRIME) + (int) (temp1 ^ (temp1 >>> 32));
		result = (result * PRIME) + Arrays.deepHashCode(this.tags);
		return result;
	}

	protected boolean canEqual(Object other) {
		return other instanceof EqualsAndHashCodeExample;
	}
}

 

반응형
반응형

자바에서 최상위 객체인 Object는 하위 객체들이 오버라이딩하여 사용하도록 설계된 메서드들이 있다. (equals, hashCode, toString, clone, finalize) 그리고 이 메서드들은 일반 규약이 존재하는데 이를 따르지 않으면 자바에서 제공하는 클래스와 함께 사용할 때 제대로 동작하지 않는다.

 

이번 장에서는 equals 메서드에 대해 설명한다.

 

Object.equals() 메서드

Object.equals() 메서드는 객체와 다른 객체가 동일한 지 여부를 반환한다. equals를 오버라이딩 하지 않았을 경우 최상위 객체인 Object의 메서드가 호출된다. 이 경우 오직 자기 자신하고만 같다. (메모리 주소가 동일)

 

아래는 Object.equals()의 코드이다.

public class Object {
	...
    
    public boolean equals(Object obj) {
        return (this == obj);
    }
}

 

Object.equals() 메서드를 오버라이딩하여 재정의할 때 준수해야 하는 일반 규약이 Object 클래스 명세서에 작성되어 있다.

 

  • It is reflexive: for any non-null reference value x, x.equals(x) should return true.
  • It is symmetric: for any non-null reference values x and y, x.equals(y) should return true if and only if y.equals(x) returns true.
  • It is transitive: for any non-null reference values x, y, and z, if x.equals(y) returns true and y.equals(z) returns true, then x.equals(z) should return true.
  • It is consistent: for any non-null reference values x and y, multiple invocations of x.equals(y) consistently return true or consistently return false, provided no information used in equals comparisons on the objects is modified.
  • For any non-null reference value x, x.equals(null) should return false.

하나씩 알아보자.

 

Reflexive : 반사성

반사성이란 모든 객체는 자기 자신과 같아야 한다는 뜻이다. 이 규약을 의도적으로 깨트릴 수는 있으나, 그럴 이유도 없고 지키지 않기도 힘들다. 아래 코드는 의도적으로 깨트렸다.

public class ViolatingReflexiveTest {
	int i;

	public static void main(String[] args) {
		ViolatingReflexiveTest test = new ViolatingReflexiveTest();
		test.i = 1;

		System.out.println(test.equals(test)); // false
	}

	@Override
	public boolean equals(Object obj) {
		return ((ViolatingReflexiveTest) obj).i < this.i;
	}
}

 

Symmetry : 대칭성

대칭성이란 X와 Y가 같으면, Y도 X와 같아야 한다는 뜻이다. 이 규약은 쉽게 깨질 수 있다.

 

예를 들어 동일한(비슷한) 의미를 가진 서로 다른 클래스인 X와 Y가 존재한다고 하자. X는 Y와 의미가 비슷하기 때문에 자기 자신 클래스뿐만 아니라 Y 클래스와 호환되도록 equals 메서드에서 Y 클래스를 입력받아서 처리하도록 설계했다.

 

하지만, Y는 X 클래스가 구현되기 전에 구현된 클래스고 자기 자신인 Y만 입력받아서 equals 메서드를 처리하도록 하였다. 따라서 X.equals(Y)는 참일 수 있지만 Y.equals(X)는 X가 자기 자신 클래스가 아니기 때문에 거짓을 항상 반환할 것이다.

public class XClass {
	public int age;

	@Override
	public boolean equals(Object obj) {
		if (obj instanceof XClass) {
			return age == ((XClass) obj).age;
		}

		// X 클래스는 Y 클래스와도 비교를 한다.
		if (obj instanceof YClass) {
			return age == ((YClass) obj).years;
		}
		return false;
	}

}


public class YClass {
	public int years;

	@Override
	public boolean equals(Object obj) {
		if (obj instanceof YClass) {
			return years == ((YClass) obj).years;
		}
		return false;
	}

	public static void main(String[] args) {
		XClass xClass = new XClass();
		YClass yClass = new YClass();

		xClass.age = 10;
		yClass.years = 10;

		System.out.println(xClass.equals(yClass)); // true
		System.out.println(yClass.equals(xClass)); // false

	}
}

 

Transitivity : 추이성

추이성이란 수학에서 많이 봤던 “a=b이고 b=c이면 a=c이다.”과 동일한 의미이다.

 

먼저 이 예제를 보이기 위해 java.awt.Point 클래스를 상속하고, 색상을 추가로 가지는 ColorPoint를 구현한다. ColorPoint의 equals 메서드는 자신과 동일한 객체만 검사하며 부모 클래스인 Point의 equals 메서드와 색상을 비교하여 객체의 동일 여부를 판단하도록 구현하였다.

 

하지만 이는 이미 대칭성(symmetric)을 위반한다. Point를 ColorPoint와 비교하면 좌표 값(x, y)을 비교하지만, ColorPoint는 자신과 동일한 객체만 검사하므로 부모인 Point가 검사대상이 될 경우 false다.

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 obj) {
		// ColorPoint 객체가 아닐 경우, 항상 false이다.
		if (!(obj instanceof ColorPoint)) {
			return false;
		}

		return super.equals(obj) && ((ColorPoint) obj).color == color;
	}

	public static void main(String[] args) {
		Point point = new Point(1, 2);
		ColorPoint colorPoint = new ColorPoint(1, 2, Color.RED);

		// Symmetry
		System.out.println(point.equals(colorPoint)); // true
		System.out.println(colorPoint.equals(point)); // false
	}
}

 

추이성을 테스트하기도 전에 대칭성을 위반해버린다. 대칭성을 지키기 위해 Point 객체가 아닐 경우 false를 리턴하도록 변경하고, Point 객체이면 색상은 제외한 좌표만 비교하는 로직을 넣게 되면 대칭성이 보존된다.

public class ColorPoint extends Point {
	private final Color color;

	public ColorPoint(int x, int y, Color color) {
		super(x, y);
		this.color = color;
	}

	public ColorPoint(Point point, Color color) {
		super(point);
		this.color = color;
	}

	@Override
	public boolean equals(Object obj) {
		// Point 객체가 아닐 경우, 항상 false를 리턴
		if (!(obj instanceof Point)) {
			return false;
		}

		// ColorPoint가 아닌 Point 객체일경우, 색상은 비교하지 않고 좌표만 비교
		if (!(obj instanceof ColorPoint)) {
			return obj.equals(this);
		}

		return super.equals(obj) && ((ColorPoint) obj).color == color;
	}

	public static void main(String[] args) {
		Point point = new Point(1, 2);
		ColorPoint redColorPoint = new ColorPoint(point, Color.RED);

		// Symmetry
		System.out.println(point.equals(redColorPoint)); // true
		System.out.println(redColorPoint.equals(point)); // true

		ColorPoint blueColorPoint = new ColorPoint(point, Color.BLUE);

		// Transitivity violation
		System.out.println(redColorPoint.equals(point)); // true
		System.out.println(point.equals(blueColorPoint)); // true
		System.out.println(redColorPoint.equals(blueColorPoint)); // false
	}

}

 

하지만 위 코드는 이제 추이성을 위반한다.

point, redColorPoint, 그리고 blueColorPoint의 좌표는 (1, 2)로 동일하다. 따라서 아래와 같이 된다.

  1. redColorPoint와 point를 비교하면 좌표만 비교하므로 true를 리턴한다.
  2. point와 blueColorPoint를 비교하면 좌표만 비교하므로 true를 리턴한다.
  3. redColorPoint == point이고, point == blueColorPoint이므로 redColorPoint == blueColorPoint여야 하지만 ColorPoint 객체는 색상까지 비교하므로 둘의 색상은 다르다. 따라서 false를 리턴하여 추이성을 위반한다.

그렇다면 위와 같이 상속을 하여 구현한 클래스의 equals는 어떻게 구현해야 할까? 상속을 받아 새로운 값을 추가하여 equals를 만들 때 추이성 규약을 위반하지 않을 방법이 없다. 부모 클래스가 존재하는 한 이는 해결할 수 없다.

 

따라서 위와 같이 상속받아 구현하였을 경우 불가능하지만, 피할 수 있는 방법은 있다. 규칙 16에서 나올 '계승하는 대신 구성하라' 규칙을 사용하는 것이다. 즉 Point를 상속하지 말고 하나의 필드로 만들어서 사용하는 방법이다. 코드는 아래와 같다.

public class CorrectColorPoint {
	// Point를 상속하지 않고 필드로 구성하였다.
	private final Point point;
	private final Color color;

	public CorrectColorPoint(int x, int y, Color color) {
		this.point = new Point(x, y);
		this.color = color;
	}

	@Override
	public boolean equals(Object obj) {
		if (obj == this) {
			return true;
		}

		if (!(obj instanceof CorrectColorPoint)) {
			return false;
		}

		CorrectColorPoint cp = (CorrectColorPoint) obj;

		return cp.point.equals(point) && cp.color.equals(color);
	}
}

 

 

equals 메서드를 구현할 때 instanceof 대신 getClass 메서드를 사용하면 상속을 하여도 추이성을 지킬 수 있다는 소문이 있다. 하지만 이는 SOLID 원칙 중 하나인 리스코프 대체 원칙(Liskov substitution principle)을 위반한다. 리스코프 대체 원칙 참고.

 

리스코프 대체 원칙은 간단하게 말하면 자식의 인스턴스를 부모의 메서드에 대입하여도 부모 메서드의 결과는 동일하다는 의미이다. 말이 어려운데 코드를 보자. 아래는 Point.equlas의 코드이다.

public class Point extends Point2D implements java.io.Serializable {
	...
    
    public boolean equals(Object obj) {
        if (obj instanceof Point) {
            Point pt = (Point)obj;
            return (x == pt.x) && (y == pt.y);
        }
        return super.equals(obj);
    }
    
}

 

이제 이 Point.equals 코드의 instanceof를 getClass로 대체하면 아래와 같다.

    public boolean equals(Object obj) {
        if (obj == null || obj.getClass() != getClass()) {
            Point pt = (Point)obj;
            return (x == pt.x) && (y == pt.y);
        }
        return false;
    }

 

이렇게 변경되면 무엇이 문제일까? Point의 equals 메서드에 자식 ColorPoint를 넣게 되면 false가 된다. 왜냐하면 항상 자신의 class, 즉 Point가 아닐 경우에는 항상 false를 리턴하기 때문이다. 이는 리스코프 대체 원칙을 위반한다.

 

Consistent : 일관성

일관성이란 일단 같다고 판정된 객체들은 이후에 변화가 없으면 계속 같아야 한다는 것이다.

 

java.net.URL의 equals 메서드는 URL에 대응되는 IP 주소를 비교하여 동일 여부를 판단하였다. 하지만 IP주소는 네트워크상에서 언제든 변경될 수 있으므로 일관성을 보장하지 않는다. 아래 코드를 보자

public class UrlEqulasTest {
	public static void main(String[] args) throws MalformedURLException, UnknownHostException {
		URL firstUrl = new URL("https://www.google.co.kr/");
		URL secondUrl = new URL("https://142.250.199.67/"); // 구글의 접속 IP는 다양하므로 테스트때마다 다름

		InetAddress address = InetAddress.getByName(firstUrl.getHost());
		System.out.println(address.getHostAddress()); // 142.250.199.67

		InetAddress address2 = InetAddress.getByName(secondUrl.getHost());
		System.out.println(address2.getHostAddress()); // 142.250.199.67

		System.out.println(firstUrl.equals(secondUrl)); // true
	}
}

필자 네트워크상으로 'https://www.google.co.kr/'의 IP는 142.250.199.67이다. 따라서 'https://142.250.199.67/'과 equlas로 비교하면 현재는 true를 반환한다.

 

하지만 시간이 흐르면 'https://www.google.co.kr/'의 IP는 DNS에 따라서 계속 변경되기 때문에 어느 순간에는 '142.250.199.67'이 아닐 수 있다. 그럴 때는 위의 결과는 false를 리턴한다.

 

이렇게 코드는 동일하지만 equlas의 결과가 변화한다면 일관성이 없는 것이다. 따라서 equals를 정의할 때는 해당 객체의 고유한 값들만을 이용하여 작성해야 한다.

 

Non-nullity : 널(Null)에 대한 비 동치성

object.equals(null)는 항상 false를 반환해야 한다.

	@Override
	public boolean equals(Object obj) {
		if(obj == null){
			return false;
		}
		
		...
	}

 

위와 같이 작성하여도 되지만 instanceof에 null을 체크할 경우, 항상 false를 반환한다. 따라서 아래와 같이 작성하여 한번에 해당 자료형인지 확인도 하면서 null인지를 확인하도록 작성하자.

	@Override
	public boolean equals(Object obj) {
		if(!(obj instanceof MyType)){
			return false;
		}
		
		...
	}

 

equals 메서드 구현 순서

  1. == 연산자를 사용하여 인자가 자기 자신인지 제일 먼저 검사하여 같다면 바로 true를 리턴한다. 성능을 위함이다.
  2. instanceof 연산자를 사용하여 인자의 자료형이 정확한지 검사한다.
  3. 인자의 자료형을 캐스팅한다.
  4. 동일함을 검사하는 필드를 각각 비교한다.
    • float와 double은 각각 Float.compare와 Double.compare를 사용하여 비교한다.
    • 필드의 비교 순서는 다를 가능성이 가장 높거나 비교 비용이 낮은 필드부터 비교하는 게 좋다.
  5. 마지막으로 equals의 일반 규약을 만족하는지 검사한다.
	@Override
	public boolean equals(Object obj) {
		// 1. 자기 자신인지 검사한다.
		if (obj == this) {
			return true;
		}
        
		// 2. 자료형을 검사한다.
		if (!(obj instanceof CorrectColorPoint)) {
			return false;
		}
        
		// 3. 캐스팅한다.
		CorrectColorPoint cp = (CorrectColorPoint) obj;

		// 4. 다를 가능성이 높은 순서대로 필드를 비교한다.
		return cp.point.equals(point) && cp.color.equals(color);
	}

 

Object.equals()를 오버라이딩 하지 않아도 되는 경우

Object.equals()를 하위 클래스에서 재정의 하지 않아도 되는 경우는 아래와 같다.

 

  • 각각의 객체가 고유하다. 클래스 특성상 객체가 고유할 수밖에 없는 경우에는 오버 라이딩할 필요가 없다. 예를 들어 Thread 같은 클래스가 있다.
  • 클래스에 논리적 동일성 검사 방법이 있건 없건 상관없다. 클래스 특성상 equals 메서드가 있어봤자 사용할 일이 거의 없을 때 오버라이딩 하지 않는다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에서 사용하기에도 적당하다. 예를 들어 대부분의 Set, List, Map 클래스들은 각각 AbstractSet, AbstractList, AbstractMap의 equals를 사용한다.

 

 

반응형
반응형

자바의 최상위 클래스인 Object 클래스에는 finalize 메서드가 존재한다.

   /**
     * Called by the garbage collector on an object when garbage collection
     * determines that there are no more references to the object.
     * A subclass overrides the {@code finalize} method to dispose of
     * system resources or to perform other cleanup.
     * <p>
     * The general contract of {@code finalize} is that it is invoked
     * if and when the Java&trade; virtual
     * machine has determined that there is no longer any
     * means by which this object can be accessed by any thread that has
     * not yet died, except as a result of an action taken by the
     * finalization of some other object or class which is ready to be
     * finalized. The {@code finalize} method may take any action, including
     * making this object available again to other threads; the usual purpose
     * of {@code finalize}, however, is to perform cleanup actions before
     * the object is irrevocably discarded. For example, the finalize method
     * for an object that represents an input/output connection might perform
     * explicit I/O transactions to break the connection before the object is
     * permanently discarded.
     * <p>
     * The {@code finalize} method of class {@code Object} performs no
     * special action; it simply returns normally. Subclasses of
     * {@code Object} may override this definition.
     * <p>
     * The Java programming language does not guarantee which thread will
     * invoke the {@code finalize} method for any given object. It is
     * guaranteed, however, that the thread that invokes finalize will not
     * be holding any user-visible synchronization locks when finalize is
     * invoked. If an uncaught exception is thrown by the finalize method,
     * the exception is ignored and finalization of that object terminates.
     * <p>
     * After the {@code finalize} method has been invoked for an object, no
     * further action is taken until the Java virtual machine has again
     * determined that there is no longer any means by which this object can
     * be accessed by any thread that has not yet died, including possible
     * actions by other objects or classes which are ready to be finalized,
     * at which point the object may be discarded.
     * <p>
     * The {@code finalize} method is never invoked more than once by a Java
     * virtual machine for any given object.
     * <p>
     * Any exception thrown by the {@code finalize} method causes
     * the finalization of this object to be halted, but is otherwise
     * ignored.
     *
     * @throws Throwable the {@code Exception} raised by this method
     * @see java.lang.ref.WeakReference
     * @see java.lang.ref.PhantomReference
     * @jls 12.6 Finalization of Class Instances
     */
    protected void finalize() throws Throwable { }

 

특정 객체에 대한 참조가 더 이상 없다고 판단할 때 가비지 컬렉션이 객체의 finalize를 호출한다. 문서에서도 나와 있듯이 하위 클래스에서 시스템 리소스를 삭제하거나 다른 정리를 수행하기 위해 finalize 메서드를 오버 라이딩하여 작성할 수 있다.

	@Override
	protected void finalize() throws Throwable {
		// do something
		super.finalize();
	}

 

하지만 이펙티브 자바에서는 finalize 사용을 피하라고 권고한다.

finalize는 예측 불가능하며, 대체로 위험하고, 일반적으로 불필요하다.

 

finalize는 언제 수행되는지도 알 수 없으며 수행을 반드시 보장하지 않는다.

1. finalize 메서드는 호출되더라도 즉시 실행된다는 보장이 없으며, 언제 수행되는지도 알 수 없다.

 

finalize는 GC가 호출하게 되는데, GC는 JVM 구현마다 크게 다르기 때문에 finalize가 언제 수행되는지는 알 수 없다. 따라서 중요한 리소스의 해제를 finalize에서 하게 된다면 finalize가 언제 호출될지 모르기 때문에 애플리케이션 실행 중에 리소스 문제가 발생할 수 있으며, 발생하여도 재현하기가 쉽지 않아 디버깅하기 어렵다.

 

2. finalize 수행을 반드시 보장하지 않는다.

 

finalize가 호출되지 않은 상태로 애플리케이션이 종료될 수 있다. 그러므로 지속성이 보장되어야 하는 중요한 상태 정보를 finalize에 작성하면 안 된다. finalize를 반드시 수행하도록 하는 System.runFinalizersOnExit(), Runtime.runFinalizersOnExit() 메서드가 존재하지만, Deprecated 되었다.

     * @deprecated  This method is inherently unsafe.  It may result in
     *      finalizers being called on live objects while other threads are
     *      concurrently manipulating those objects, resulting in erratic
     *      behavior or deadlock.
     * @param value indicating enabling or disabling of finalization
     * @throws  SecurityException
     *        if a security manager exists and its <code>checkExit</code>
     *        method doesn't allow the exit.
     *
     * @see     java.lang.Runtime#exit(int)
     * @see     java.lang.Runtime#gc()
     * @see     java.lang.SecurityManager#checkExit(int)
     * @since   JDK1.1
     */
    @Deprecated
    public static void runFinalizersOnExit(boolean value) {
        Runtime.runFinalizersOnExit(value);
    }

 

 

finalize에서 발생하는 예외는 무시된다.

finalize 메서드 안에서 예외가 발생한다고 하더라도, 해당 예외는 무시되며 스택 트레이스도 표시되지 않는다. 또한 해당 finalize 메서드도 중단된다.

 

아래 예제를 보자.

public class ExceptionInFinalizeTest {

	public static void main(String[] args) throws Throwable {
		ExceptionInFinalizeTest exceptionInFinalizeTest = new ExceptionInFinalizeTest();
		exceptionInFinalizeTest = null;

		// System.gc does not guarantee finalize, but generally works fine.
		System.gc();
	}

	@Override
	protected void finalize() throws Throwable {
		System.out.println("The finalize method start");

		// Exceptions are ignored.
		System.out.println(2 / 0);

		super.finalize();

		System.out.println("The finalize method end"); // not printed
	}
}

 

ExceptionInFinalizeTest 객체를 생성하고, GC를 발생하도록 하기 위해 null로 하여 레퍼런스를 제거하였다. 이후 gc를 발생시켜 finalize()를 호출되게 하였다. finalize의 수행은 항상 보장하지는 않지만 테스트에서는 항상 호출되었다.

 

finalize에 예외를 발생시키도록 divde by zero를 수행하였지만, 어떠한 스택트레이스도 남지 않았으며 이후 코드도 수행되지 않고 종료된다.

 

 

finalize를 재정의할 경우 성능 저하가 발생한다.

finalize를 재정의 하는 것만으로도 성능 저하가 발생한다.

 

아래 에제를 보자.

public class FinalizePerformanceTest {

	/**
	 * TODO Implement performance test case for finalize
	 */
	public static void main(String[] args) {
		long start = System.nanoTime();
		for (int i = 0; i < 1000000; i++) {
			new FinalizePerformanceTest();
		}
		long end = System.nanoTime();
		System.out.println("time: " + (end - start));

	}

	@Override
	protected void finalize() throws Throwable {
		super.finalize();
	}

}

필자의 컴퓨터에서는 finalize를 재정의하여 테스트를 할 경우 위 결과는 152730300ns가 발생하였다. finalize 재정의를 하지 않을 경우 3501900ns 소요되었다. 약 43배 느리다.

 

finalize 사용방법 및 구현 방법

그럼 도대체 이렇게 단점이 많은 finalize는 어디에 사용할까?

 

1. 명시적 종료 메서드 패턴에서 호출되지 않을 것을 대비하기 위한 방어 역할

 

명시적 종료 메서드란 자원을 사용하고 나서 사용을 마쳤으면 메모리 해제를 명시적으로 하도록 만든 메서드를 의미한다. 대표적으로 FileInputStream, FileOutputSteam, Timer, Connection이 있다. 하지만 API 개발자는 항상 클라이언트가 API를 올바르게 사용하지 않을 수도 있다는 것을 고려해야 한다. 따라서 명시적으로 종료 메서드를 호출하지 않았을 경우를 대비하여 finalize 메서드에 메모리 해제를 하도록 작성한다.

 

아래는 java.io.FileInputStream 클래스의 코드이다.

public class FileInputStream extends InputStream
{
    ...

    /**
     * Ensures that the <code>close</code> method of this file input stream is
     * called when there are no more references to it.
     *
     * @exception  IOException  if an I/O error occurs.
     * @see        java.io.FileInputStream#close()
     */
    protected void finalize() throws IOException {
        if ((fd != null) &&  (fd != FileDescriptor.in)) {
            /* if fd is shared, the references in FileDescriptor
             * will ensure that finalizer is only called when
             * safe to do so. All references using the fd have
             * become unreachable. We can call close()
             */
            close();
        }
    }
}

 

2. 네이티브 피어(native peer) 리소스를 해제할 때

먼저 네이티브(native)란 자바 외의 C나 C++ 등 다른 언어로 작성된 프로그램을 나타낸다. 이런 프로그램을 자바에서 다루기 위해 만들어놓은 객체를 네이티브 피어라고 한다. 그리고 일반적인 클래스들이 이러한 네이티브 피어를 이용해 네이티브 프로그램을 사용한다.

 

이 네이티브 피어(객체)는 일반 객체가 아니기 때문에 GC가 관리하지 않는다. 그렇기 때문에 해당 네이티브 피어를 사용하는 일반적인 클래스의 finalize 메서드에서 해당 네이티브 객체의 리소스를 해제하는 코드를 작성할 수 있다.

 

 

finalize를 사용한다면 반드시 부모의 finalize를 호출해야 한다. 그렇지 않으면, 부모 클래스는 절대 종료되지 않는다.

	@Override
	protected void finalize() throws Throwable {
		try {
			// do something
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			super.finalize();
		}
	}

 

하지만 개발자는 항상 실수를 한다. 아래 예를 들어보자.

 

AClass는 finalize를 재정의하여 반드시 유한한 자원의 메모리를 해제해야 하는 클래스를 구현했다고 가정하자. AClass는 부모의 finalize를 반드시 호출해야 한다는 것을 알고 아래와 같이 구현하였다.

public class AClass {

	private void myClose() {
		System.out.println("Do Something");
	}

	@Override
	protected void finalize() throws Throwable {
		try {
			myClose();
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			super.finalize();
		}
	}

}

 

BClass는 AClass를 상속받고 마찬가지로 finalize를 재정의했다. 하지만 BClass는 실수로 super.finliaze()를 호출하지 않았다. 그럼 AClass의 finalize는 호출되지 않아 myClose는 호출되지 않는다.

 

이렇게 하위 클래스에서 잘못 코딩하여 발생할 수 있는 finalize 문제를 부모 클래스에서 막을 수 있는데 이를 Finalizer Guardian 패턴이라고 한다.

 

이는 GC의 기본원리를 이용하여 finalize를 호출하는 방식이다. 먼저 코드는 아래와 같다.

public class AClass {

	private final Object guardian = new Object() {
		@Override
		protected void finalize() throws Throwable {
			myClose();
		}
	};

	private void myClose() {
		System.out.println("Do Something");
	}

}

 

기존에 finalize에서 호출되었던 myClose() 메서드가 guardian이라는 변수에 선언된 익명 클래스의 finalize에 선언되어 있다. 즉, AClass의 자원 해제 역할을 익명 클래스가 대신한다.

 

A 클래스를 재정의한 B 클래스의 객체가 더 이상 레퍼런스가 없어 gc가 발생하게 된다면, B 객체에 존재하는 guaridan에 참조되어 있는 익명 클래스의 객체도 누구도 사용할 수 없다. 그러므로 익명 클래스의 객체도 gc 대상이 되므로 해당 finalize가 반드시 호출된다. 따라서 멍청한 하위 클래스가 부모의 finalize를 호출하지 않아도 문제가 발생하지 않는다.

 

 

요약

자원 반환에 대한 최종적 방어 로직 또는 네이티브 자원을 종료시키려는 것이 아니라면 finalize를 사용하지 말자.

 

 

반응형

+ Recent posts