[이펙티브 자바] 아이템 13-18

diveintoo·2022년 4월 29일
0

이펙티브 자바

목록 보기
3/6

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

Cloneable 인터페이스는 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스이다.

하지만 clone() 메서드는 Cloneable 인터페이스가 아닌 Object 클래스에 선언이 되어있다.

Cloneable

메서드가 하나도 선언되어 있지 않은 Cloneable 인터페이스는 Object의 protected 메서드인 clone() 메서드의 동작 방식을 결정힌다.

Cloneable을 구현한 클래스의 인스턴스에서 clone()을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며, Cloneable을 구현하지 않은 클래스에서 이를 호출하면 CloneNotSupportedException을 던진다.

clone() 메소드는 클래스에 정의된 모든 필드가 기본 타입이거나 불변 객체를 참조하는 경우에 완벽하게 맞는다.

예시: 가변 객체를 참조하지 않는 상태용 clone()

@Override
public PhoneNumber clone() {
	 try {
 		return (PhoneNumber) super.clone();
 	} catch (CloneNotSupportedException e) {
		 throw new AssertionError(); // 일어날 수 X
	 }

편안하네요.

가변 객체를 참조할 때의 clone()

1. clone()을 재귀적으로 호출

@Override
public Stack clone() {
	try {
 		Stack result = (Stack) super.clone();
 		result.elements = elements.clone();
 		return result;
 	 } catch (CloneNotSupportedException e) {
 throw new AssertionError();
 }

위의 코드와 같이 clone() 메서드가 super.clone()의 결과를 그대로 반환한다면 반환된 인스턴스의 size 필드는 올바른 값을 갖지만, elements 필드는 원본 Stack 인스턴스와 같은 배열을 참조한다.
따라서 원본이나 복제본 인스턴스 중 하나를 수정하면 다른 하나도 같이 수정되어 불변식을 해치게 된다.

배열의 clone()은 런타임 타입과 컴파일타임 타입 모두가 원본 배열과 똑같은 배열을 반환하므로 형변환이 필요없다.

2. 연결 리스트를 재귀적으로 복사

public class HashTable implements Cloneable {
	 private Entry[] buckets = ...;
 
	 private static class Entry {
		 final Object key;
		 Object value;
		 Entry next;
         
         this.key = key;
         this.value = value;
         this.next = next;
	 }
 
 // 이 엔트리가 가리키는 연결 리스트를 재귀적으로 복사
 Entry deepCopy() {
	 return new Entry(key, value,
	 next == null ? null : next.deepCopy());
 }
 
 @Override
 public HashTable clone() {
	 try {
		 HashTable result = (HashTable) super.clone();
		 result.buckets = new Entry[buckets.length];
		 for (int i = 0; i < buckets.length; i++) {
			 if (buckets[i] != null)
				 result.buckets[i] = buckets[i].deepCopy();
		 }
		return result;
	 } catch (CloneNotSupportedException e) {
 		throw new AssertionError();
 	 }
 }

재귀적인 호출로 인해 연결 리스트의 원소 수만큼 스택 프레임을 소비하기 때문에 연결 리스트가 길다면 StackOverflow를 일으킬 위험이 있다.

3. 연결리스트를 반복적으로 복사

Entry deepCopy() {
 	Entry result = new Entiry(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;
}

deepCopy()를 재귀적으로 호출하는 대신 반복자를 써서 순회하는 방향으로 수정했다.

정리

  • 상속용 클래스는 Cloneable를 구현해서는 안 된다.
  • Cloneable를 구현한 Threadsafe 클래스를 작성할 때는 clone 메소드 역시 적절히 동기화 해주어야 한다.

아이템 14. Comparable을 구현할지 고려하라

Comparable의 compareTo() 메소드는 단순 동치성 비교뿐만 아니라 순서까지 비교할 수 있으며 제네릭하다.

compareTo() 메소드의 일반규약

  • Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x))여야 한다(따라서 x.compareTo(y)는 y.compareTo(x)가 예외를 던질 때에 한해 예외를 던져야 한다).
  • Comparable을 구현한 클래스는 추이성을 보장해야 한다. 즉, (x.compareTo(y) > 0 && y.compareTo(z) > 0)이면 x.compareTo(z) > 0이다.
  • Comparable을 구현한 클래스는 모든 z에 대해 x.compareTo(y) == 0이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z)) > 0이다.
  • (x.compareTo(y) == 0) == (x.equals(y))여야 한다. Comparable을 구현하고 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다.

마지막 규약은 필수는 아니지만 꼭 지키는 것이 좋다.

compareTo() 메소드 작성 요령

  • compareTo() 메서드는 각 필드의 순서를 비교한다.
  • 객체 참조 필드를 비교하려면 compareTo() 메서드를 재귀적으로 호출한다.
  • Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면 비교자(Comparator)를 대신 사용한다.

예시: 객체 참조 필드가 하나뿐인 비교자

public class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {
    public int compareTo(CaseInsensitiveString cis) {
        return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
    }
    // 나머지 코드 생략
}

예시: 기본 타입 필드가 여럿일 때의 비교자

public int compareTo(Number n) {
        int result = Integer.compare(first, n.first); // 가장 중요한 필드
        if (result == 0) {
            result = Integer.compare(second, n.second); // 두 번째로 중요한 필드

            if (result == 0) {
                result = Integer.compare(third, n.third); // 세 번째로 중요한 필드
            }
        }

        return result;
    }
  • 핵심 필드가 여러 개라면 가장 핵심적인 필드부터 비교해나가는 것이 좋다.
  • 비교 결과가 0이 아니라면 즉시 순서가 결정되면서 그 결과를 반환한다.

정리

  • 순서를 고려해야 하는 값 클래스를 작성할 경우 꼭 Comparable 인터페이스를 구현해야 한다.
  • 그 인스턴스들은 쉽게 정렬하고, 검색하고, 비교 기능을 제공하는 컬렉션과 어우러져야 한다.
  • compareTo()는 필드의 값을 비교할 때 ‘<‘와 ‘>’ 연산자는 쓰지 말도록 한다.
    • 대신 박싱된 기본 타입 클래스가 제공하는 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 사용한다.

아이템 15. 클래스와 멤버의 접근 권한을 최소화하라

정보 은닉의 장점

  • 시스템 개발 속도를 높일 수 있다.
    • 여러 컴포넌트를 병렬로 개발할 수 있기 때문
  • 시스템 관리 비용을 낮춘다.
    • 각 컴포넌트를 더 빨리 파악하여 디버깅할 수 있고, 다른 컴포넌트로 교체하는 부담이 적어지므로
  • 정보 은닉 자체가 성능을 높여주지는 않지만, 성능 최적화에 도움을 준다.
    • 완성된 시스템을 프로파일링해 최적화할 컴포넌트를 정한 다음 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 최적화할 수 있기 때문
  • 소프트웨어 재사용성을 증대시킨다.
    • 외부에 의존하지 않고 독자적으로 동작할 수 있는 컴포넌트라면 그 컴포넌트와 함께 개발되지 않은 낯선 환경에서도 유용하게 쓰일 가능성이 크기 때문
  • 큰 시스템을 제작하는 난이도를 낮춰준다.
    • 시스템 전체가 아직 완성되지 않은 상태에서도 개별 컴포넌트의 동작을 검증할 수 있기 때문이다.

접근 제한자

  • private : 멤버를 선언한 톱레벨 클래스에서만 접근할 수 있다.
  • package-private : 멤버가 소속된 패키지 안의 모든 클래스에서 접근할 수 있다. 접근 제한자를 명시하지 않았을 때 적용되는 패키지 접근 수준(단, 인터페이스의 멤버는 기본적으로 public이 적용).
  • protected : package-private의 접근 범위를 포함하며, 이 멤버를 선언한 클래스의 하위 클래스에서도 접근할 수 있다.
  • public : 모든 곳에서 접근할 수 있다.

접근 제한자의 기본 원칙은 모든 클래스와 멤버의 접근성을 가능한 한 좁혀야한다.

-> 클래스의 공개 API를 제외한 모든 멤버는 private로 만든다.

-> 그 후 오직 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한하여 package-private로 풀어주자.

상위 클래스의 메서드를 재정의할 때는 그 접근 수준을 상위 클래스에서보다 더 좁게 설정할 수 없다.

public 클래스의 인스턴스 필드는 되도록 public이 아니어야 합니다.

  • 필드가 가변 객체를 참조하거나, final이 아닌 인스턴스 필드를 public으로 선언하면 그 필드와 관련된 모든 것은 불변식을 보장할 수 없게 된다.

클래스에서 public static final 배열 필드를 두거나 이 필드를 반환하는 접근자 메서드를 제공해서는 안 된다.

  • 이런 필드나 접근자를 제공한다면 클라이언트에서 그 배열의 내용을 수정할 수 있게 된다.

아이템 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라

public 필드 사용

class Point {
    public double x;
    public double y;
}
  • 데이터 필드에 직접 접근할 수 있으니 캡슐화의 이점을 제공하지 못한다.
  • API를 수정하지 않고는 내부 표현을 바꿀 수 없다.
  • 불변식을 보장할 수 없다.

접근자와 변경자 메소드 사용

class Point {
  private double x;
  private double y;

  public Point2(double x, double y) {
    this.x = x;
    this.y = y;
  }

  public double getX() {
    return x;
  }

  public double getY() {
    return y;
  }

  public void setX(double x) {
    this.x = x;
  }
  public void setY(double y) {
    this.y = y;
  }
}
  • private field를 사용하여 직접적인 접근을 막는다.
  • getter와 setter를 통해 내부 표현 방식의 유연성을 얻는다.
  • package-private(default class) 혹은 private 중첩 클래스라면 데이터 필드를 노출한다 해도 문제가 없다.

정리

  • public 클래스는 절대 가변 필드를 직접 노출해서는 안 된다.
  • 불변 필드라면 노출해도 덜 위험하지만 완전히 안심할 수는 없다. (item 15의 배열의 경우)
  • package-private 클래스나 중첩 클래스에서는 종종 필드를 노출하는 편이 나을 때도 있다.

아이템 17. 변경 가능성을 최소화하라

불변 클래스

  • 인스턴스의 내부 값을 수정할 수 없는 클래스를 말한다.
  • 대표적으로 String, BigInteger, BigDecimal 등이 이에 속한다.
  • 사용 이유
    • 설계, 구현, 사용이 용이하다.
    • Thread-safe하고 오류가 생길 여지가 적다.

불변 클래스를 만들 때 지켜야할 5가지 규칙

1. 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다.

2. 클래스를 확장할 수 없도록 한다.

  • 객체의 상태를 변하게 만드는 사태를 막아준다.
  • 상속을 막는 대표적인 방법은 클래스를 final로 선언하는 것이다.

3. 모든 필드를 final로 선언한다.

  • 필드의 수정을 막겠다는 설계자의 의도를 드러내는 방법이다.

4. 모든 필드를 private으로 선언한다.

  • 필드가 참조하는 가변 객체를 클라이언트에서 직접 접근하여 수정하는 일을 막아 준다.

5. 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.

예시: 불변 복소수 클래스

public final class Complex {
    private final double re;
    private final double im;

    public Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    public double realPart() {
        return re;
    }

    public double imaginaryPart() {
        return im;
    }

    public Complex plus(Complex c) {
        return new Complex(re + c.re, im + c.im);
    }

    public Complex minus(Complex c) {
        return new Complex(re * c.re, im - c.im);
    }

    public Complex times(Complex c) {
        return new Complex(re * c.re - im * c.im,
                re * c.im + im * c.re);
    }

    public Complex dividedBy(Complex c) {
        double tmp = c.re * c.re + c.im * c.im;
        return new Complex((re * c.re + im * c.im) / tmp,
                (im * c.re - re * c.im) / tmp);
    }

    @Override
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof Complex))
            return false;
        Complex c = (Complex) o;

        return Double.compare(c.re, re) == 0
                && Double.compare(c.im, im) == 0;
    }

    @Override
    public int hashCode() {
        return 31 * Double.hashCode(re) + Double.hashCode(im);
    }

    @Override
    public String toString() {
        return "(" + re + " + " + im + "i)";
    }
}
  • 실수부와 허수부 값을 반환하는 접근자 메서드가 정의되어 있고, 사칙연산을 위한 메서드들이 제공되고 있다.
  • 사칙연산 메서드들은 인스턴스 자신은 수정하지 않고 새로운 Complex 인스턴스를 만들어 반환한다.
  • 피연산자에 함수를 적용해 그 결과를 반환하지만, 피연산자 자체는 그대로이다.(함수형 프로그래밍)
    • 해당 메서드가 객체의 값을 변경하지 않는다.

불변 객체의 장점

  • 생성된 시점의 상태를 파괴될 때까지 그대로 간직한다.
  • Thread-safe하여 따로 동기화할 필요가 없다.
  • 안심하고 공유할 수 있다.

불변 객체의 단점

값이 다르면 반드시 독립된 객체로 만들어야 한다.

  • 값의 가짓수가 많다면 이들을 모두 만드는 데 큰 비용이 들게 된다.

불변 클래스를 만드는 또 다른 설계 방법

-> 모든 생성자를 private 혹은 package-private으로 만들고 public 정적 팩토리를 제공한다.

public class Complex {
    private final double re;
    private final double im;
    
    private Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }
    
    public static Complex valueOf(double re, double im) {
        return new Complex(re, im);
    }
    
    //나머지 코드 생략
}
  • final 클래스를 없애고, 생성자를 private으로 바꾼 뒤 정적 팩토리 메서드를 통해 불변 객체를 제공하고 있다.
  • 패키지 바깥의 클라이언트에서 바라본 이 불변 객체는 사실상 final 클래스와 이다.
    • public이나 protected 생성자가 없으니 다른 패키지에서 이 클래스를 확장할 방법이 없기 때문이다.

정리

  • 클래스는 꼭 필요한 경우가 아니라면 불변이어야 한다.

  • 불변으로 만들 수 없는 클래스라도 변경할 수 있는 부분을 최소한으로 줄이자.

  • 다른 합당한 이유가 없다면 모든 필드는 private final이어야 한다.


아이템 18. 상속보다는 컴포지션을 사용하라

상속은 코드를 재사용하는 강력한 수단이지만 다음과 같은 문제가 있다.

상속은 캡슐화를 깨뜨린다.

  • 상속은 하위 클래스가 상위 클래스에 대한 내부 구현 정보를 알게 한다.
  • 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.

예시: 상속을 잘못 사용한 경우

public class InstrumentedHashSet<E> extends HashSet<E> {

    //추가된 원소의 수
    private int addCount = 0;

    public InstrumentedHashSet() {}

    public InstrumentedHashSet(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    //접근자 메소드
    public int getAddCount() {
        return addCount;
    }
}
  • HashSet을 상속하고, 처음 생성된 이후 원소 몇 개가 더해졌는지 알 수 있는 기능을 추가했다.
  • addAll()을 호출할 시 오동작한다.
    • HashSet의 addAll() 메서드가 add() 메서드를 사용하여 구현되기 때문이다.

컴포지션 사용

기존의 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 한다.

-> 컴포지션(Composition)

새 클래스의 인스턴스 메서드들은 private 필드로 참조하는 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다.

-> 전달(forwarding)

컴포지션의 장점

  • 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않게 된다.
  • 새로운 클래스는 기존의 클래스의 내부 구현에 대해 알지 못하게 되어 캡슐화 원칙도 잘 지켜지게 된다.

예시: 컴포지션과 전달 방식 사용

// 래퍼 클래스
public class InstrumentedSet<E> extends ForwardingSet<E> {
    private int addCount = 0;

    public InstrumentedSet(Set<E> s) {
        super(s);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

// 전달 클래스
public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;

    public ForwardingSet(Set<E> s) {
        this.s = s;
    }

    @Override
    public void clear() { s.clear(); }

    @Override
    public boolean contains(Object o) { return s.contains(o); }

    @Override
    public boolean isEmpty() { return s.isEmpty(); }

    @Override
    public int size() { return s.size(); }

    @Override
    public Iterator<E> iterator() { return s.iterator(); }

    @Override
    public boolean add(E e) { return s.add(e); }

    @Override
    public boolean remove(Object o) { return s.remove(o); }

    @Override
    public boolean containsAll(Collection<?> c) { return s.containsAll(c); }

    @Override
    public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }

    @Override
    public boolean removeAll(Collection<?> c) { return s.removeAll(c); }

    @Override
    public boolean retainAll(Collection<?> c) { return s.retainAll(c); }

    @Override
    public Object[] toArray() { return s.toArray(); }

    @Override
    public <T> T[] toArray(T[] a) { return s.toArray(a); }

    @Override
    public int hashCode() { return s.hashCode(); }

    @Override
    public boolean equals(Object obj) { return s.equals(obj); }

    @Override
    public String toString() { return s.toString(); }
}
  • Set 인터페이스를 구현했고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공한다.
  • 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만들어준다.
    • 상속 방식은 구체 클래스 각각을 따로 확장해야 한다.
    • 하지만 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 기능을 확장할 수 있다.
  • 다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라고 한다.
  • 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다.
  • 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다.

정리

  • 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 사용한다.
  • 상속의 취약점을 피하려면 컴포지션과 전달을 사용하자.
  • 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 좋다.

0개의 댓글